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

+8 -1
View File
@@ -150,7 +150,12 @@ export async function runConfigBackup(
});
}
export function registerConfigBackupRoutes(app: FastifyInstance): void {
export interface ConfigBackupRouteDeps {
/** Re-read discord.json into the running bridge (it caches its config and would keep the pre-restore token). */
reloadDiscord?: () => Promise<void>;
}
export function registerConfigBackupRoutes(app: FastifyInstance, deps: ConfigBackupRouteDeps = {}): void {
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
@@ -236,6 +241,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
if (restored.includes('intents.json')) intentStore.reload();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (restored.includes('clis.json')) reloadCliRegistry();
// The Discord bridge caches discord.json and rewrites nothing from it, so a restore must reach the running bridge.
if (restored.includes('discord.json')) void deps.reloadDiscord?.().catch(() => undefined);
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
if (isMultiUserMode()) {
void appendAdminAudit({
+222
View File
@@ -0,0 +1,222 @@
/**
* @fileoverview Discord bridge settings and the deliberate-post route (src/discord-bridge.ts).
*
* GET /api/discord the config WITHOUT its token, plus gateway state and last result
* PUT /api/discord change any field; an empty `token` clears it
* POST /api/discord/test post one test message to the parent channel
* POST /api/sessions/:id/discord-notify a CLI asks for the user (physical step, blocker, timed-out watch)
*
* The bot token is a bearer secret, so it is stored in its own 0600 file and never returned. In
* multi-user mode the three settings routes are admin only: the server receives every session's
* events. `discord-notify` follows the session's own ownership rules instead.
*/
import { readFile, realpath, stat } from 'node:fs/promises';
import { basename, isAbsolute, relative, sep } from 'node:path';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { canAccessOwned, findSessionOrFail, getAuthUser, isAdmin, parseBody } from '../route-helpers.js';
import type { SessionPort } from '../ports/session-port.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { DiscordNotifySchema, DiscordUpdateSchema } from '../schemas.js';
import {
discordConfigProblem,
MAX_FILE_BYTES,
maskToken,
readDiscordConfig,
writeDiscordConfig,
type DiscordBridge,
} from '../../discord-bridge.js';
import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js';
export interface DiscordRouteDeps {
bridge: DiscordBridge;
configDir: string;
ctx: SessionPort;
hostTitle: () => string;
}
/**
* A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at
* least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id.
*/
function resolveSessionId(ctx: SessionPort, req: FastifyRequest, id: string): string {
if (ctx.sessions.has(id) || id.length < 8) return id;
// Only sessions the caller may see count, so another user's session is never revealed by its prefix.
const user = getAuthUser(req);
const hits = [...ctx.sessions.values()].filter((s) => s.id.startsWith(id) && canAccessOwned(user, s.owner));
return hits.length === 1 ? hits[0].id : id;
}
// A loop or a prompt-injected agent must not ping the phones until Discord answers 429, nor hold 8 MB
// uploads in memory without bound. Per session, refilled continuously (the same shape as paste-image's).
const NOTIFY_PER_MINUTE = 6;
const notifyBuckets = new Map<string, { tokens: number; at: number }>();
function consumeNotifyToken(sessionId: string, now: number = Date.now()): boolean {
if (notifyBuckets.size > 1000) {
for (const [k, b] of notifyBuckets) if (now - b.at > 3_600_000) notifyBuckets.delete(k);
}
const b = notifyBuckets.get(sessionId) ?? { tokens: NOTIFY_PER_MINUTE, at: now };
b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + ((now - b.at) * NOTIFY_PER_MINUTE) / 60_000);
b.at = now;
notifyBuckets.set(sessionId, b);
if (b.tokens < 1) return false;
b.tokens -= 1;
return true;
}
/** Give back a token spent on a request that was refused before anything was posted. */
function refundNotifyToken(sessionId: string): void {
const b = notifyBuckets.get(sessionId);
if (b) b.tokens = Math.min(NOTIFY_PER_MINUTE, b.tokens + 1);
}
/** Test hook: forget every bucket between runs. */
export function _resetNotifyBuckets(): void {
notifyBuckets.clear();
}
/**
* A file a session may send: a regular file, at most MAX_FILE_BYTES, whose REAL path (symlinks
* followed) lies inside the session's working folder. Anything else is refused, so a CLI cannot be
* talked into posting ~/.codeman/discord.json or an ssh key. Returns the bytes, or the reason.
*/
async function fileProblem(
workingDir: string,
remote: boolean,
file: string
): Promise<{ name: string; data: Uint8Array } | string> {
if (remote) return 'Files can only be sent from a local session';
if (!isAbsolute(file)) return 'The file path must be absolute';
// Bounded first: realpath on a dead mount would hang the request.
if ((await probePathKind(workingDir, { pastCap: true })) !== 'directory') {
return describeUnknownPath('The session folder', workingDir, { pastCap: true });
}
// The agent chose this path: probe it bounded too, before realpath can hold a worker on a dead mount.
const kind = await probePathKind(file, { pastCap: true });
if (kind === 'unknown') return describeUnknownPath('The file', file, { pastCap: true });
if (kind !== 'file') return 'Not a regular file';
try {
const [root, real] = await Promise.all([realpath(workingDir), realpath(file)]);
const rel = relative(root, real);
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
return 'The file must be inside the session folder';
const info = await stat(real);
if (!info.isFile()) return 'Not a regular file';
if (info.size > MAX_FILE_BYTES) return `The file is over ${MAX_FILE_BYTES / 1024 / 1024} MB`;
return { name: basename(real), data: new Uint8Array(await readFile(real)) };
} catch {
return 'The file could not be read';
}
}
export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDeps): void {
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
return null;
};
const status = async (): Promise<DiscordStatus> => {
const { token, ...rest } = await readDiscordConfig(deps.configDir);
return {
...rest,
hasToken: token !== '',
tokenMasked: maskToken(token),
gateway: deps.bridge.gatewayState,
gatewayError: deps.bridge.gatewayError,
lastResult: deps.bridge.lastResult,
};
};
app.get('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
const no = denied(req, reply);
if (no) return no;
return { success: true, data: await status() };
});
app.put('/api/discord', async (req, reply): Promise<ApiResponse<DiscordStatus>> => {
const no = denied(req, reply);
if (no) return no;
const patch = parseBody(DiscordUpdateSchema, req.body, 'Invalid Discord settings');
const current = await readDiscordConfig(deps.configDir);
const next: DiscordConfig = {
...current,
...patch,
token: patch.token !== undefined ? patch.token.trim() : current.token,
};
const problem = discordConfigProblem(next);
if (problem) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
try {
await writeDiscordConfig(deps.configDir, next);
await deps.bridge.reload();
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
return { success: true, data: await status() };
});
app.post('/api/discord/test', async (req, reply): Promise<ApiResponse<DiscordResult>> => {
const no = denied(req, reply);
if (no) return no;
const cfg = await readDiscordConfig(deps.configDir);
if (!cfg.token || !cfg.parentChannelId) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save the bot token and parent channel ID first');
}
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether Discord did.
return { success: true, data: await deps.bridge.sendTest(cfg, deps.hostTitle()) };
});
app.post('/api/sessions/:id/discord-notify', async (req, reply): Promise<ApiResponse<DiscordResult | null>> => {
const { id: rawId } = req.params as { id: string };
const id = resolveSessionId(deps.ctx, req, rawId);
const { message, urgent, file } = parseBody(DiscordNotifySchema, req.body, 'Invalid Discord message');
const session = findSessionOrFail(deps.ctx, id, req);
if (!consumeNotifyToken(session.id)) {
reply.code(429);
reply.header('Retry-After', '60');
return createErrorResponse(
ApiErrorCode.RATE_LIMITED,
`Too many messages (${NOTIFY_PER_MINUTE} per minute per session)`
);
}
let upload: { name: string; data: Uint8Array } | undefined;
if (file !== undefined) {
const problem = await fileProblem(session.workingDir, !!session.remote, file);
if (typeof problem === 'string') {
refundNotifyToken(session.id);
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
}
upload = problem;
}
const result = await deps.bridge.notify(
{
event: 'session:discord-notify',
title: urgent ? 'Needs you now' : 'Message from the CLI',
body: message,
urgency: urgent ? 'critical' : 'warning',
sessionId: session.id,
sessionName: session.name,
host: deps.hostTitle(),
file: upload,
},
// deliberate: an agent's own message is never deduped, so a second, different post is not lost.
{ urgent: urgent === true, deliberate: true }
);
if (result === null) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, 'The Discord bridge is not enabled');
}
return { success: true, data: result };
});
}
+1
View File
@@ -35,6 +35,7 @@ export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export { registerDiscordRoutes } from './discord-routes.js';
export { registerDoctorRoutes } from './doctor-routes.js';
export {
registerCustomModelRoutes,
+15 -13
View File
@@ -2920,16 +2920,14 @@ export function registerSessionRoutes(
return candidates[0] ?? null;
}
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
// The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript
// the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic.
const lastResponseFor = async (session: Session, context?: string) => {
// Codex sessions don't write to ~/.claude/projects — their transcripts
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
// response-viewer works for Codex panes too.
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
const codexQuery = req.query as { context?: string };
return await readCodexLastResponse(session, codexQuery.context === 'full');
return await readCodexLastResponse(session, context === 'full');
}
// DeepSeek Harness writes a real structured transcript under
@@ -2948,8 +2946,7 @@ export function registerSessionRoutes(
// would starve on an answer that exists. Those configurations keep the
// pane segmenter below: coarse, but the real conversation.
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const full = context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
if (transcript) {
return {
@@ -2967,7 +2964,6 @@ export function registerSessionRoutes(
// the pane IS the transcript for these CLIs. Codex is already handled above,
// where a real rollout file is the better source.
if (isExternalCliTranscriptMode(session.mode)) {
const externalQuery = req.query as { context?: string };
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
// buffer when there is no tmux pane to ask (direct PTY, tests).
@@ -2980,7 +2976,7 @@ export function registerSessionRoutes(
text: getLastTranscriptResponse(blocks),
timestamp: '',
hasContext: blocks.length > 0,
messages: externalQuery.context === 'full' ? blocks : undefined,
messages: context === 'full' ? blocks : undefined,
};
}
@@ -3006,11 +3002,10 @@ export function registerSessionRoutes(
}
}
const query = req.query as { context?: string };
// `turn` is the brief view's context: the last ANSWERED turn's assistant
// messages, so a multi-row answer is not reduced to its final row. `text`
// stays the last assistant row in every mode (agent pollers hash it).
const wantsMessages = query.context === 'full' || query.context === 'turn';
const wantsMessages = context === 'full' || context === 'turn';
const claudeSessionId = session.claudeSessionId || session.id;
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
if (!transcript) {
@@ -3031,7 +3026,7 @@ export function registerSessionRoutes(
try {
const content = await fs.readFile(transcript.path, 'utf8');
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
if (query.context === 'turn') {
if (context === 'turn') {
return {
text: parsed.text,
timestamp: parsed.timestamp,
@@ -3042,6 +3037,13 @@ export function registerSessionRoutes(
} catch {
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
}
};
ctx.lastResponseFor = lastResponseFor;
app.get('/api/sessions/:id/last-response', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
return lastResponseFor(session, (req.query as { context?: string }).context);
});
function isCodexInjectedContext(text: string): boolean {