Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All @@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand Down Expand Up @@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand Down Expand Up @@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All @@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All @@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand Down Expand Up @@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All @@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand Down Expand Up @@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All @@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand Down Expand Up @@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
158 changes: 96 additions & 62 deletions packages/web/src/features/chat/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({ prisma, chatId, userId }: { prisma: PrismaClient, chatId: string, userId: string | undefined }): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All @@ -79,6 +79,55 @@
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 2 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
             fs.mkdirSync(chatDir, { recursive: true });
         }
 
-        const chatFile = path.join(chatDir, `${chatId}.json`);
+        // Sanitize chatId to prevent path traversal or invalid filename characters.
+        let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+        if (!safeChatId) {
+            safeChatId = 'unknown-chat';
+        }
+
+        const chatFile = path.join(chatDir, `${safeChatId}.json`);
         fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
     }
 };
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
if (!safeChatId) {
safeChatId = 'unknown-chat';
}

const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand Down Expand Up @@ -112,6 +161,11 @@
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All @@ -133,7 +187,7 @@
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({ prisma, chatId, userId: user?.id });

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand Down Expand Up @@ -170,24 +224,7 @@
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand Down Expand Up @@ -295,54 +332,51 @@
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
},
});
Comment thread
brendan-kellam marked this conversation as resolved.

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand Down Expand Up @@ -436,7 +470,7 @@

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({ prisma, chatId, userId: user?.id });
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand Down Expand Up @@ -617,7 +651,7 @@
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({ prisma, chatId, userId: user?.id });
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/features/chat/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface AgentOptions {
inputSources: Source[];
onWriteSource: (source: Source) => void;
traceId: string;
chatId: string;
}

export const createAgentStream = async ({
Expand All @@ -32,6 +33,7 @@ export const createAgentStream = async ({
selectedRepos,
onWriteSource,
traceId,
chatId,
}: AgentOptions) => {
// For every file source, resolve the source code so that we can include it in the system prompt.
const fileSources = inputSources.filter((source) => source.type === 'file');
Expand Down Expand Up @@ -84,6 +86,7 @@ export const createAgentStream = async ({
onStepFinish: ({ toolResults }) => {
toolResults.forEach(({ toolName, output, dynamic }) => {
captureEvent('wa_chat_tool_used', {
chatId,
toolName,
success: !isServiceError(output),
});
Expand Down
Loading