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)) {