diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 4da8b1df0..3712c810e 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -82,15 +82,23 @@ function relativeTime(iso: string): string { /** Clean up Bugbot comment body - extract title and description, remove HTML junk */ function cleanBugbotComment(body: string): { title: string; description: string } { - // Extract title (first ### heading) - const titleMatch = body.match(/###\s+(.+?)(?:\n|$)/); - const title = titleMatch ? titleMatch[1].replace(/\*\*/g, "").trim() : "Comment"; + // Check if this is a Bugbot comment (has structured markers) + const isBugbot = body.includes("") || body.includes("### "); - // Extract description between DESCRIPTION START/END comments - const descMatch = body.match(/\s*([\s\S]*?)\s*/); - const description = descMatch ? descMatch[1].trim() : body.split("\n")[0] || "No description"; + if (isBugbot) { + // Extract title (first ### heading) + const titleMatch = body.match(/###\s+(.+?)(?:\n|$)/); + const title = titleMatch ? titleMatch[1].replace(/\*\*/g, "").trim() : "Comment"; - return { title, description }; + // Extract description between DESCRIPTION START/END comments + const descMatch = body.match(/\s*([\s\S]*?)\s*/); + const description = descMatch ? descMatch[1].trim() : body.split("\n")[0] || "No description"; + + return { title, description }; + } else { + // For non-Bugbot comments, use full body as description + return { title: "Comment", description: body.trim() }; + } } /** Builds a GitHub branch URL from PR owner/repo/branch. */ @@ -217,7 +225,9 @@ export function SessionDetail({ session }: SessionDetailProps) { > #{pr.number} - · + {(session.branch || session.issueUrl) && ( + · + )} )} @@ -237,7 +247,9 @@ export function SessionDetail({ session }: SessionDetailProps) { {session.branch} )} - · + {session.issueUrl && ( + · + )} )} diff --git a/packages/web/src/server/terminal-websocket.ts b/packages/web/src/server/terminal-websocket.ts index ad1f463a9..6e74ef6d0 100644 --- a/packages/web/src/server/terminal-websocket.ts +++ b/packages/web/src/server/terminal-websocket.ts @@ -18,6 +18,7 @@ import { createServer, request } from "node:http"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { loadConfig } from "@agent-orchestrator/core"; +import { validateIdentifier } from "../lib/validation.js"; interface TtydInstance { sessionId: string; @@ -154,17 +155,24 @@ function getOrSpawnTtyd(sessionId: string): TtydInstance { // Use once() for cleanup handlers to prevent race condition when both exit and error fire proc.once("exit", (code) => { console.log(`[Terminal] ttyd ${sessionId} exited with code ${code}`); - instances.delete(sessionId); - // Recycle port for reuse - availablePorts.add(port); + // Only delete if this is still the current instance (prevents race with error handler) + const current = instances.get(sessionId); + if (current?.process === proc) { + instances.delete(sessionId); + // Recycle port for reuse + availablePorts.add(port); + } }); proc.once("error", (err) => { console.error(`[Terminal] ttyd ${sessionId} error:`, err.message); - // Clean up instance on spawn error to prevent leak - instances.delete(sessionId); - // Recycle port for reuse - availablePorts.add(port); + // Only delete if this is still the current instance (prevents race with exit handler) + const current = instances.get(sessionId); + if (current?.process === proc) { + instances.delete(sessionId); + // Recycle port for reuse + availablePorts.add(port); + } // Kill any running process try { proc.kill(); @@ -216,6 +224,14 @@ const server = createServer(async (req, res) => { return; } + // Validate session ID to prevent path traversal and injection + const idErr = validateIdentifier(sessionId, "session"); + if (idErr) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: idErr })); + return; + } + // Validate session exists before spawning ttyd using configured dataDir const sessionPath = join(config.dataDir, sessionId); if (!existsSync(sessionPath)) {