Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
371 changes: 371 additions & 0 deletions apps/desktop/src/lib/trpc/routers/chat-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createChatServiceRouter as buildRouter,
ChatService,
} from "@superset/chat/host";
import { sessionStateSchema } from "@superset/chat/schema";
import { convertExternalSessionToChatChunks } from "@superset/chat/shared";
import { TRPCError } from "@trpc/server";
import fg from "fast-glob";
import { env } from "main/env.main";
import { appState } from "main/lib/app-state";
import { getHashedDeviceId } from "main/lib/device-info";
import { notificationsEmitter } from "main/lib/notifications/server";
import { NOTIFICATION_EVENTS } from "shared/constants";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { loadToken } from "../auth/utils/auth-functions";

interface ClaudeSessionSummary {
id: string;
title: string;
filePath: string;
projectId: string;
lastModifiedAt: string;
}

type FileStat = Awaited<ReturnType<typeof stat>>;
type ClaudeSessionRootKind = "projects" | "transcripts";

interface ClaudeSessionRoot {
kind: ClaudeSessionRootKind;
rootDir: string;
}

function resolveLifecycleTargets(sessionId: string): Array<{
paneId: string;
tabId: string;
Expand Down Expand Up @@ -43,6 +70,318 @@ function resolveLifecycleTargets(sessionId: string): Array<{
return targets;
}

function getClaudeSessionRoots(): ClaudeSessionRoot[] {
const baseDir = path.join(os.homedir(), ".claude");
return [
{
kind: "projects",
rootDir: path.join(baseDir, "projects"),
},
{
kind: "transcripts",
rootDir: path.join(baseDir, "transcripts"),
},
];
}

function encodeClaudeProjectPath(cwd: string): string {
const normalized = path.resolve(cwd).replace(/\\/g, "/");
const withoutDrive = normalized.replace(/^[A-Za-z]:/, "");
const segments = withoutDrive
.split("/")
.filter(Boolean)
.map((segment) => segment.replace(/[^A-Za-z0-9_-]/g, "-"));
return `-${segments.join("-")}`;
}

function isWithinDirectory(rootDir: string, targetPath: string): boolean {
const relative = path.relative(rootDir, targetPath);
return (
relative.length > 0 &&
!relative.startsWith("..") &&
!path.isAbsolute(relative)
);
}
Comment on lines +97 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isWithinDirectory rejects the root directory itself (relative.length > 0), which is correct, but does not guard against symlink escapes.

path.relative computes a textual relative path — if the target is a symlink pointing outside claudeProjectsRoot, the check passes but the resolved file is outside the intended boundary. Since followSymbolicLinks: false is set in fast-glob (line 103), listed paths won't follow symlinks, but importClaudeSession receives filePath from user input directly and calls path.resolve + readFile on it.

Consider using fs.realpath on normalizedFilePath before the containment check to guard against symlink-based traversal.

🔒 Proposed fix
+import { realpath } from "node:fs/promises";
+
 async function importClaudeSession(args: {
 	filePath: string;
 	organizationId: string;
 	workspaceId: string;
 }): Promise<{...}> {
 	const claudeProjectsRoot = getClaudeProjectsRoot();
-	const normalizedFilePath = path.resolve(args.filePath);
+	const normalizedFilePath = await realpath(path.resolve(args.filePath));
+	const resolvedRoot = await realpath(claudeProjectsRoot);
 	if (!existsSync(claudeProjectsRoot)) {
 		...
 	}
-	if (!isWithinDirectory(claudeProjectsRoot, normalizedFilePath)) {
+	if (!isWithinDirectory(resolvedRoot, normalizedFilePath)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/chat-service/index.ts` around lines 81 -
88, The isWithinDirectory check should resolve symlinks before comparing paths:
in importClaudeSession, call fs.realpath (or fs.realpathSync) on
normalizedFilePath and on claudeProjectsRoot, then pass those real paths into
isWithinDirectory (or update isWithinDirectory to perform the realpath
resolution internally) so containment is checked against resolved targets
(protecting against symlink escapes); also allow the root directory itself
(remove the relative.length > 0 rejection or treat an empty relative as inside)
so files that are exactly the root are permitted. Ensure you reference/modify
the functions isWithinDirectory and importClaudeSession and the variable
normalizedFilePath to implement this fix.


function findClaudeSessionRootForPath(
targetPath: string,
): ClaudeSessionRoot | null {
const normalizedFilePath = path.resolve(targetPath);
for (const root of getClaudeSessionRoots()) {
if (!existsSync(root.rootDir)) continue;
if (isWithinDirectory(root.rootDir, normalizedFilePath)) {
return root;
}
}
return null;
}

async function listClaudeSessions(args: {
cwd: string;
limit: number;
}): Promise<ClaudeSessionSummary[]> {
const { cwd, limit } = args;
const roots = getClaudeSessionRoots().filter((root) => existsSync(root.rootDir));
if (roots.length === 0) return [];

Comment on lines +122 to +126
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

CI formatter failure — fix formatting.

The pipeline flags lines 122–126 as incorrectly formatted. Run bunx biome check --write on this file before merging.

🧰 Tools
🪛 GitHub Actions: CI

[error] 122-126: Formatter would have printed the following content: fix formatting (prettier).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/chat-service/index.ts` around lines 122 -
126, The CI formatter failure means this file's whitespace/formatting doesn't
match project style; run the project's formatter (bunx biome check --write) on
apps/desktop/src/lib/trpc/routers/chat-service/index.ts or manually reformat the
block around the function that returns Promise<ClaudeSessionSummary[]> (the
lines using const { cwd, limit } = args; const roots =
getClaudeSessionRoots().filter((root) => existsSync(root.rootDir)); if
(roots.length === 0) return [];), ensuring spacing and line breaks conform to
biome rules so the CI check passes.

const rootEntries = await Promise.all(
roots.map(async (root) => {
try {
const filePaths = await fg("**/*.jsonl", {
cwd: root.rootDir,
absolute: true,
onlyFiles: true,
unique: true,
followSymbolicLinks: false,
suppressErrors: true,
});
return filePaths.map((filePath) => ({ filePath, root }));
} catch {
return [] as Array<{ filePath: string; root: ClaudeSessionRoot }>;
}
}),
);

const allEntries = rootEntries.flat();
if (allEntries.length === 0) return [];

const workspaceProjectId = encodeClaudeProjectPath(cwd);
const workspaceMatches = allEntries.filter(({ filePath, root }) => {
if (root.kind !== "projects") return false;
const relative = path.relative(root.rootDir, filePath).replace(/\\/g, "/");
const [projectId] = relative.split("/");
return projectId === workspaceProjectId;
});

const candidates = workspaceMatches.length > 0 ? workspaceMatches : allEntries;

const withStats: Array<{
filePath: string;
fileStat: FileStat;
root: ClaudeSessionRoot;
}> = [];
for (const { filePath, root } of candidates) {
try {
const fileStat = await stat(filePath);
withStats.push({ filePath, fileStat, root });
} catch {}
}

return withStats
.sort((a, b) => b.fileStat.mtime.getTime() - a.fileStat.mtime.getTime())
.slice(0, limit)
.map(({ filePath, fileStat, root }) => {
const projectId =
root.kind === "projects"
? (path
.relative(root.rootDir, filePath)
.replace(/\\/g, "/")
.split("/")[0] ?? "unknown")
: "transcripts";
const id = path.basename(filePath, ".jsonl");
return {
id,
title: id,
filePath,
projectId,
lastModifiedAt: fileStat.mtime.toISOString(),
};
});
}

function getMessageTextFromChunk(
chunk: ReturnType<
typeof convertExternalSessionToChatChunks
>["messages"][number],
): string {
for (const part of chunk.message.parts) {
if (part.type === "text" && typeof part.text === "string") {
return part.text.trim();
}
}
return "";
}

function deriveImportedSessionTitle(args: {
filePath: string;
messages: ReturnType<typeof convertExternalSessionToChatChunks>["messages"];
}): string {
const { filePath, messages } = args;
const firstUserText = messages
.filter((message) => message.message.role === "user")
.map(getMessageTextFromChunk)
.find((text) => text.length > 0);
if (firstUserText) {
return firstUserText.slice(0, 120);
}
return path.basename(filePath, ".jsonl");
}

async function getAuthHeaders(): Promise<Record<string, string>> {
const { token } = await loadToken();
if (!token) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be signed in to import Claude sessions",
});
}
return {
Authorization: `Bearer ${token}`,
};
}

async function importClaudeSession(args: {
filePath: string;
organizationId: string;
workspaceId: string;
}): Promise<{
sessionId: string;
title: string;
importedMessages: number;
ignoredEntries: number;
}> {
const normalizedFilePath = path.resolve(args.filePath);
const root = findClaudeSessionRootForPath(normalizedFilePath);
if (!root) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid Claude session path",
});
}
if (!normalizedFilePath.endsWith(".jsonl")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Claude session file must be .jsonl",
});
}

const sessionFileContent = await readFile(normalizedFilePath, "utf8");
const converted = convertExternalSessionToChatChunks({
input: sessionFileContent,
providerId: "claude-code",
});
if (converted.messages.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No importable messages found in Claude session",
});
}
Comment on lines +258 to +268
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

convertExternalSessionToChatChunks is called without a try/catch — malformed JSONL throws a raw exception.

If the .jsonl file is corrupt or uses an unexpected format, the conversion will throw and the tRPC mutation will surface as a generic INTERNAL_SERVER_ERROR instead of a descriptive BAD_REQUEST. Wrap the call so the caller gets a meaningful error.

🛡️ Proposed fix
-	const converted = convertExternalSessionToChatChunks({
-		input: sessionFileContent,
-		providerId: "claude-code",
-	});
-	if (converted.messages.length === 0) {
-		throw new TRPCError({
-			code: "BAD_REQUEST",
-			message: "No importable messages found in Claude session",
-		});
-	}
+	let converted: ReturnType<typeof convertExternalSessionToChatChunks>;
+	try {
+		converted = convertExternalSessionToChatChunks({
+			input: sessionFileContent,
+			providerId: "claude-code",
+		});
+	} catch (err) {
+		throw new TRPCError({
+			code: "BAD_REQUEST",
+			message: `Failed to parse Claude session file: ${err instanceof Error ? err.message : String(err)}`,
+		});
+	}
+	if (converted.messages.length === 0) {
+		throw new TRPCError({
+			code: "BAD_REQUEST",
+			message: "No importable messages found in Claude session",
+		});
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/chat-service/index.ts` around lines 258 -
268, Wrap the call to convertExternalSessionToChatChunks in a try/catch so
malformed or unexpected .jsonl content produces a descriptive TRPCError instead
of bubbling a raw exception; catch errors around
convertExternalSessionToChatChunks (while keeping readFile as-is), and on
failure throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid Claude
session file: ${error.message}` }) (optionally log the error) before proceeding
to the existing check for converted.messages.length === 0.


const headers = await getAuthHeaders();
const sessionId = randomUUID();
const createResponse = await fetch(
`${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}`,
{
method: "PUT",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
organizationId: args.organizationId,
workspaceId: args.workspaceId,
}),
},
);
if (!createResponse.ok) {
const detail = await createResponse.text().catch(() => "");
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to create imported session (${createResponse.status}): ${detail || "unknown error"}`,
});
}

const usedMessageIds = new Set<string>();
let seqCounter = 0;
for (const [index, chunk] of converted.messages.entries()) {
const baseMessageId =
typeof chunk.message.id === "string" && chunk.message.id.trim().length > 0
? chunk.message.id.trim()
: `imported-${index}`;
let messageId = baseMessageId;
let duplicateCounter = 1;
while (usedMessageIds.has(messageId)) {
messageId = `${baseMessageId}-${duplicateCounter++}`;
}
usedMessageIds.add(messageId);

const createdAtRaw = chunk.message.createdAt;
const createdAt =
typeof createdAtRaw === "string"
? createdAtRaw
: createdAtRaw instanceof Date
? createdAtRaw.toISOString()
: new Date().toISOString();
const role = chunk.message.role;
const actorId =
role === "user"
? "imported-claude-user"
: role === "assistant"
? "imported-claude-assistant"
: "imported-claude-system";

const event = sessionStateSchema.chunks.insert({
key: `${messageId}:0`,
value: {
messageId,
actorId,
role,
chunk: JSON.stringify({
type: "whole-message",
message: {
...chunk.message,
id: messageId,
createdAt,
},
}),
seq: seqCounter++,
createdAt,
},
});

const appendResponse = await fetch(
`${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}/stream`,
{
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify(event),
},
);
if (!appendResponse.ok) {
const detail = await appendResponse.text().catch(() => "");
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to append imported message (${appendResponse.status}): ${detail || "unknown error"}`,
});
}
}
Comment on lines +294 to +360
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sequential per-message HTTP POST with no timeout — slow imports and no partial-failure recovery.

Each converted message is appended via a separate fetch POST (line 301-311). For sessions with many messages this will be slow and latency-bound. Additionally:

  1. No AbortSignal / timeout on any of the fetch calls in this file — a hung API will block the tRPC mutation indefinitely and tie up the Electron main process.
  2. Partial import on failure: if an append fails at message N, the session already exists with messages 0..N-1 but the mutation throws. The caller gets an error with no way to resume or clean up the partially imported session.

Consider batching messages (if the API supports it), adding a timeout via AbortSignal.timeout(ms), and either cleaning up on failure or returning partial-success info so the caller can handle it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/chat-service/index.ts` around lines 253 -
319, The current loop builds per-message events and calls fetch for each (see
usedMessageIds, seqCounter, sessionStateSchema.chunks.insert, event and the
fetch to `${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}/stream`), which is
slow and has no timeout or resume/cleanup on failure; change this by batching
the events into a single array (collect events instead of POSTing per iteration)
and POST the batch once to the stream endpoint (or a new batch endpoint), add an
AbortSignal timeout for the fetch (use AbortSignal.timeout(ms)) to avoid
hanging, and on fetch failure return structured partial-result info (e.g., count
of successfully created events and their messageIds) or call a cleanup path to
delete the partially-created session so callers can resume or handle partial
imports.


const title = deriveImportedSessionTitle({
filePath: normalizedFilePath,
messages: converted.messages,
});
if (title.trim().length > 0) {
await fetch(`${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}`, {
method: "PATCH",
headers: {
...headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ title: title.trim() }),
}).catch(() => {});
}
Comment on lines +366 to +375
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Title PATCH failure is silently swallowed — imported session may appear untitled.

The .catch(() => {}) on line 374 means if the title update fails (e.g., network hiccup), the imported session will have no title and the user gets no feedback. Since the import already succeeded at this point, consider at least logging the failure or returning a flag indicating the title wasn't set.

♻️ Proposed fix
 	if (title.trim().length > 0) {
 		await fetch(`${env.NEXT_PUBLIC_API_URL}/api/chat/${sessionId}`, {
 			method: "PATCH",
 			headers: {
 				...headers,
 				"Content-Type": "application/json",
 			},
 			body: JSON.stringify({ title: title.trim() }),
-		}).catch(() => {});
+		}).catch((err) => {
+			console.warn(`[claude] failed to set imported session title: ${sessionId}`, err);
+		});
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/chat-service/index.ts` around lines 366 -
375, The PATCH request that updates the session title (the fetch call using
env.NEXT_PUBLIC_API_URL with sessionId and body { title: title.trim() }) is
silently swallowing failures via .catch(() => {}); change this to surface
failures by either logging the error (use an existing logger or console.error)
inside the catch or by propagating a boolean/flag back from the surrounding
handler to indicate title update failure so callers can react; ensure you
reference the same headers and only attempt the request when title.trim().length
> 0, and preserve existing success flow while recording/reporting any fetch
error for debugging/user feedback.


return {
sessionId,
title,
importedMessages: converted.messages.length,
ignoredEntries: converted.ignoredEntries,
};
}

const service = new ChatService({
deviceId: getHashedDeviceId(),
apiUrl: env.NEXT_PUBLIC_API_URL,
Expand All @@ -69,6 +408,38 @@ const service = new ChatService({

export const createChatServiceRouter = () => buildRouter(service);

export const createChatServiceClaudeRouter = () =>
router({
listSessions: publicProcedure
.input(
z.object({
cwd: z.string().min(1),
limit: z.number().int().min(1).max(200).default(30),
}),
)
.query(async ({ input }) => {
return listClaudeSessions({
cwd: input.cwd,
limit: input.limit,
});
}),
importSession: publicProcedure
.input(
z.object({
filePath: z.string().min(1),
organizationId: z.string().min(1),
workspaceId: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
return importClaudeSession({
filePath: input.filePath,
organizationId: input.organizationId,
workspaceId: input.workspaceId,
});
}),
});
Comment on lines +411 to +441
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

listSessions uses publicProcedure — no auth check.

importSession validates auth inside importClaudeSession via getAuthHeaders(), but listSessions has no authentication. This allows any renderer context (or potentially a malicious script via IPC) to enumerate Claude session files on disk without being signed in. If this is intentional for UX, fine — just flagging for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/chat-service/index.ts` around lines 370 -
400, The listSessions endpoint is using publicProcedure (function name:
listSessions) which lacks authentication and can expose local Claude session
files via listClaudeSessions; change it to require auth the same way
importSession does (see importSession -> importClaudeSession which uses
getAuthHeaders) by switching listSessions to the protected/authenticated
procedure wrapper or explicitly validate auth (call the same getAuthHeaders or
auth-check helper before invoking listClaudeSessions) so only authenticated
users can enumerate sessions.


export type ChatServiceDesktopRouter = ReturnType<
typeof createChatServiceRouter
>;
Loading
Loading