mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge pull request #741: feat(discord): two-way Discord bridge with a thread per tab
This commit is contained in:
commit
7c8d440928
39 files changed
+4940
-24
No files matched your search
@@ -49,6 +49,8 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
|
||||
stopTranscriptWatcher: () => {},
|
||||
persistDeb: { cancelKey: () => {} },
|
||||
sse: { cleanupSessionBatches: () => {} },
|
||||
// A real delete also tells the Discord bridge, which frees its per-tab state and may archive the thread.
|
||||
discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} },
|
||||
broadcast: () => {},
|
||||
});
|
||||
return server;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a
|
||||
* recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1.
|
||||
*/
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js';
|
||||
|
||||
const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' };
|
||||
|
||||
function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) {
|
||||
const post = vi.fn<JsonPost>(async () => ({ status, error, delivery }));
|
||||
return post;
|
||||
}
|
||||
|
||||
describe('runNotify', () => {
|
||||
it('refuses outside a Codeman session, before any request', async () => {
|
||||
const post = fake(200);
|
||||
const r = await runNotify({ message: 'hi' }, {}, '/work', post);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/inside a Codeman session/);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts the message to this session with the session id URL-encoded', async () => {
|
||||
const post = fake(200);
|
||||
const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post);
|
||||
expect(r).toEqual({ code: 0, out: 'Posted to Discord' });
|
||||
expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', {
|
||||
message: 'press the reset button',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => {
|
||||
const post = fake(200);
|
||||
await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' });
|
||||
await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' });
|
||||
});
|
||||
|
||||
it('lets --session and --url override the environment', async () => {
|
||||
const post = fake(200);
|
||||
await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post);
|
||||
expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']);
|
||||
});
|
||||
|
||||
it('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => {
|
||||
const r = await runNotify(
|
||||
{ message: 'x' },
|
||||
ENV,
|
||||
'/w',
|
||||
fake(200, undefined, { ok: false, error: 'The bot token was refused' })
|
||||
);
|
||||
expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' });
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0);
|
||||
});
|
||||
|
||||
it('postJson gives up on a server that never answers, with status 0', async () => {
|
||||
const server = createServer(() => undefined);
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const r = await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100);
|
||||
expect(r.status).toBe(0);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('postJson settles when the server sends headers and then stalls the body', async () => {
|
||||
const server = createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' });
|
||||
res.write('{"success":');
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a disabled bridge (409) as success that says so', async () => {
|
||||
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
|
||||
expect(r.code).toBe(0);
|
||||
expect(r.out).toMatch(/bridge is off/);
|
||||
});
|
||||
|
||||
it('fails with the server error, or the status, or a plain message when nothing answered', async () => {
|
||||
expect(
|
||||
(await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err
|
||||
).toBe('Could not post to Discord: The file must be inside the session folder');
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(429))).err).toMatch(/Too many messages/);
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)');
|
||||
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postJson (real transport)', () => {
|
||||
let server: Server;
|
||||
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
async function serve(status: number, reply: unknown) {
|
||||
const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' };
|
||||
server = createServer((req, res) => {
|
||||
req.on('data', (c) => (seen.body += c));
|
||||
req.on('end', () => {
|
||||
seen.method = req.method;
|
||||
seen.url = req.url;
|
||||
seen.type = req.headers['content-type'];
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(reply));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
||||
}
|
||||
|
||||
it('sends a JSON POST and reports the status and the server error text', async () => {
|
||||
const { seen, url } = await serve(400, { success: false, error: 'nope' });
|
||||
expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({
|
||||
status: 400,
|
||||
error: 'nope',
|
||||
});
|
||||
expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' });
|
||||
expect(JSON.parse(seen.body)).toEqual({ message: 'hi' });
|
||||
});
|
||||
|
||||
it('answers status 0 when nothing is listening', async () => {
|
||||
const { url } = await serve(200, {});
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
expect((await postJson(url, '/x', {})).status).toBe(0);
|
||||
server = createServer().listen(0); // so afterEach has something to close
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,216 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no
|
||||
* Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by
|
||||
* kind. The bridge and routes are tested on their own; this pins the lines that make the feature
|
||||
* exist. A real WebServer, never started, with the bridge replaced by a recorder.
|
||||
*
|
||||
* Port: N/A (no server start).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
|
||||
vi.mock('web-push', () => ({
|
||||
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
|
||||
}));
|
||||
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
|
||||
type Internals = {
|
||||
pushStore: { getAll: () => never[]; dispose: () => void };
|
||||
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
|
||||
sessions: Map<string, unknown>;
|
||||
mux: { capturePaneText?: (name: string) => string | null };
|
||||
lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>;
|
||||
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
|
||||
broadcast: (event: string, data: unknown) => void;
|
||||
cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>;
|
||||
discardPartiallyBuiltSession: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function server(): Internals {
|
||||
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals;
|
||||
s.pushStore = { getAll: () => [], dispose: () => undefined };
|
||||
s.discordBridge = {
|
||||
notify: vi.fn(async () => null),
|
||||
sessionCreated: vi.fn(async () => undefined),
|
||||
sessionRenamed: vi.fn(async () => undefined),
|
||||
sessionDeleted: vi.fn(async () => undefined),
|
||||
forgetTab: vi.fn(),
|
||||
sessionIdle: vi.fn(async () => undefined),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
beforeEach(() => sendNotification.mockClear());
|
||||
|
||||
describe('bridge deps', () => {
|
||||
it('agentExited is true only for a session whose pane exit was observed', () => {
|
||||
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
|
||||
discordBridge: { deps: { agentExited: (id: string) => boolean } };
|
||||
};
|
||||
s.sessions.set('dead', { paneExit: { observedAt: 1 } });
|
||||
s.sessions.set('alive', {});
|
||||
const { agentExited } = s.discordBridge.deps;
|
||||
expect(agentExited('dead')).toBe(true);
|
||||
expect(agentExited('alive')).toBe(false);
|
||||
expect(agentExited('missing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPushNotifications -> Discord', () => {
|
||||
it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' });
|
||||
const capture = vi.fn(() => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n');
|
||||
s.mux = { capturePaneText: capture };
|
||||
await s.sendPushNotifications('hook:permission_prompt', {
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
tool_name: 'Bash',
|
||||
});
|
||||
expect(s.discordBridge.notify).toHaveBeenCalledTimes(1);
|
||||
const msg = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect(msg).toMatchObject({
|
||||
event: 'hook:permission_prompt',
|
||||
title: 'Permission Required',
|
||||
body: '[w1-app] Tool: Bash',
|
||||
urgency: 'critical',
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1-app',
|
||||
});
|
||||
// A function, so the screen is read (a synchronous tmux exec) only once the bridge will post.
|
||||
expect(typeof msg.tail).toBe('function');
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
expect(msg.tail()).toBe('Which option?\n 1. Cap GPUs\n 2. Cap CPU');
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' });
|
||||
const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' }));
|
||||
s.lastResponseFor = reader;
|
||||
s.mux = { capturePaneText: () => 'screen\n❯\n status' };
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' });
|
||||
const idle = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect(await idle.fullText()).toEqual({
|
||||
text: 'The full answer.\n\n- one\n- two',
|
||||
version: '2026-10-30T00:00:00Z',
|
||||
});
|
||||
expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut
|
||||
await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' });
|
||||
const dialog = s.discordBridge.notify.mock.calls[1][0];
|
||||
expect(dialog.fullText).toBeUndefined();
|
||||
expect(dialog.tail()).toBe('screen\n❯\n status');
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])(
|
||||
'a finished turn in a %s tab posts the whole last message',
|
||||
async (mode) => {
|
||||
const s = server();
|
||||
s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode });
|
||||
s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' }));
|
||||
s.mux = { capturePaneText: () => 'screen' };
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' });
|
||||
const msg = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?');
|
||||
}
|
||||
);
|
||||
|
||||
it('a shell tab has no turns, so it keeps the screen tail', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' });
|
||||
s.lastResponseFor = vi.fn(async () => ({ text: 'x' }));
|
||||
s.mux = { capturePaneText: () => 'prompt$' };
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' });
|
||||
const msg = s.discordBridge.notify.mock.calls[0][0];
|
||||
expect(await msg.fullText()).toBeUndefined();
|
||||
expect(msg.tail()).toBe('prompt$');
|
||||
});
|
||||
|
||||
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' });
|
||||
expect(s.discordBridge.notify.mock.calls[0][0].tail()).toBe('plain answer');
|
||||
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' });
|
||||
expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends nothing for an event Web Push does not carry', async () => {
|
||||
const s = server();
|
||||
await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' });
|
||||
expect(s.discordBridge.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('broadcast -> Discord tab lifecycle', () => {
|
||||
it('routes created, updated and deleted by kind, reading both payload shapes', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } });
|
||||
expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1', 123);
|
||||
s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' });
|
||||
expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed');
|
||||
});
|
||||
|
||||
it('does not touch the thread on session:deleted, which a detach also fires', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionDeleted, { id: 's1' });
|
||||
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => {
|
||||
const s = server();
|
||||
await s.cleanupSession('gone', false);
|
||||
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
|
||||
await s.cleanupSession('gone', true);
|
||||
expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone');
|
||||
});
|
||||
|
||||
it('a discarded half-built session clears the per-tab state but keeps its thread', async () => {
|
||||
const s = server();
|
||||
s.sessions.set('rebuild', {
|
||||
owner: 'u',
|
||||
ralphTracker: { stopWatchingFixPlan: () => undefined, fullReset: () => undefined },
|
||||
removeAllListeners: () => undefined,
|
||||
stop: async () => undefined,
|
||||
});
|
||||
await s.discardPartiallyBuiltSession('rebuild');
|
||||
expect(s.discordBridge.forgetTab).toHaveBeenCalledWith('rebuild');
|
||||
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores an event with no session id', () => {
|
||||
const s = server();
|
||||
s.broadcast(SseEvent.SessionUpdated, {});
|
||||
expect(s.discordBridge.sessionRenamed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the typed hint only for a local external CLI: not Claude, a shell, a remote or a Docker tab', () => {
|
||||
const s = server();
|
||||
s.sessions.set('cx', { mode: 'codex' });
|
||||
s.sessions.set('cl', { mode: 'claude' });
|
||||
s.sessions.set('sh', { mode: 'shell' });
|
||||
s.sessions.set('rm', { mode: 'codex', remote: { label: 'box' } });
|
||||
s.sessions.set('dk', { mode: 'codex', docker: { image: 'x' } });
|
||||
for (const id of ['cx', 'cl', 'sh', 'rm', 'dk', 'missing']) s.broadcast(SseEvent.SessionIdle, { id });
|
||||
expect(s.discordBridge.sessionIdle.mock.calls).toEqual([
|
||||
['cx', true],
|
||||
['cl', false],
|
||||
['sh', false],
|
||||
['rm', false],
|
||||
['dk', false],
|
||||
['missing', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('closes the bridge when the server stops', async () => {
|
||||
const s = server();
|
||||
await (s as unknown as { stop: () => Promise<void> }).stop();
|
||||
expect(s.discordBridge.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* @fileoverview The Discord startup hint reaches Claude as `--append-system-prompt`, only while the
|
||||
* bridge and its `startupHint` setting are on, and never changes a launch that does not use it.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getCli } from '../src/config/cli-registry/registry.js';
|
||||
import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js';
|
||||
import {
|
||||
DiscordBridge,
|
||||
DISCORD_STARTUP_HINT,
|
||||
getDiscordStartupHint,
|
||||
writeDiscordConfig,
|
||||
DEFAULT_DISCORD_CONFIG,
|
||||
} from '../src/discord-bridge.js';
|
||||
|
||||
const SID = '0f9c2b14-1111-2222-3333-444455556666';
|
||||
const BASE = 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"';
|
||||
|
||||
function launch(mode: 'claude' | 'codex'): string | undefined {
|
||||
return buildSpawnCommandFromRegistry(getCli(mode)!, { mode, sessionId: SID, claudeCliVersion: null });
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'discord-hint-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
function bridge() {
|
||||
return new DiscordBridge({
|
||||
configDir: dir,
|
||||
fetchImpl: async () => new Response('{}'),
|
||||
sendInput: async () => true,
|
||||
socketFactory: () => ({ send() {}, close() {}, on() {} }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('startup hint on the Claude launch', () => {
|
||||
it('is absent by default, so a launch is byte-identical to before', () => {
|
||||
expect(getDiscordStartupHint()).toBeUndefined();
|
||||
expect(launch('claude')).toBe(BASE);
|
||||
});
|
||||
|
||||
it('is appended, single-quoted, while the bridge is on', async () => {
|
||||
await writeDiscordConfig(dir, {
|
||||
...DEFAULT_DISCORD_CONFIG,
|
||||
enabled: true,
|
||||
token: 'tok',
|
||||
guildId: '111111111111111111',
|
||||
parentChannelId: '222222222222222222',
|
||||
allowedUserIds: ['333333333333333333'],
|
||||
});
|
||||
const b = bridge();
|
||||
await b.reload();
|
||||
expect(getDiscordStartupHint()).toBe(DISCORD_STARTUP_HINT);
|
||||
expect(launch('claude')).toBe(`${BASE} --append-system-prompt '${DISCORD_STARTUP_HINT}'`);
|
||||
expect(DISCORD_STARTUP_HINT).not.toContain("'");
|
||||
expect(launch('codex')).not.toContain('append-system-prompt');
|
||||
b.stop();
|
||||
expect(launch('claude')).toBe(BASE);
|
||||
});
|
||||
|
||||
it('is absent when startupHint is switched off', async () => {
|
||||
await writeDiscordConfig(dir, {
|
||||
...DEFAULT_DISCORD_CONFIG,
|
||||
enabled: true,
|
||||
token: 'tok',
|
||||
guildId: '111111111111111111',
|
||||
parentChannelId: '222222222222222222',
|
||||
allowedUserIds: ['333333333333333333'],
|
||||
startupHint: false,
|
||||
});
|
||||
const b = bridge();
|
||||
await b.reload();
|
||||
expect(launch('claude')).toBe(BASE);
|
||||
b.stop();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user