Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

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

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed
-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
+### Added
+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

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

In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
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 4 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
Loading