Skip to content
5 changes: 3 additions & 2 deletions static/app/utils/analytics/seerAnalyticsEvents.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {Organization} from 'sentry/types/organization';
import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types';

export type SeerAnalyticsEventsParameters = {
'ai_query.applied': {
Expand Down Expand Up @@ -142,7 +143,7 @@ export type SeerAnalyticsEventsParameters = {
conversations_url: string | undefined;
explorer_url: string | undefined;
langfuse_url: string | undefined;
run_id: number | undefined;
run_id: SeerExplorerRunId | undefined;
type: 'positive' | 'negative';
};
'seer.explorer.global_panel.opened': {
Expand All @@ -164,7 +165,7 @@ export type SeerAnalyticsEventsParameters = {
};
'seer.explorer.session_link_copied': Record<string, unknown>;
'seer.explorer.timed_out': {
run_id: number | null;
run_id: SeerExplorerRunId | null;
};
};

Expand Down
1 change: 0 additions & 1 deletion static/app/views/dashboards/createFromSeerUtils.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ function makeSession(
overrides: Partial<NonNullable<SeerExplorerResponse['session']>> = {}
): NonNullable<SeerExplorerResponse['session']> {
return {
run_id: 1,
status: 'completed',
updated_at: new Date().toISOString(),
blocks: [],
Expand Down
8 changes: 6 additions & 2 deletions static/app/views/seerExplorer/components/chat/assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {trackAnalytics} from 'sentry/utils/analytics';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useSessionStorage} from 'sentry/utils/useSessionStorage';
import {getConversationsUrlForExternalUse} from 'sentry/views/explore/conversations/utils/urlParams';
import type {Block} from 'sentry/views/seerExplorer/types';
import type {Block, SeerExplorerRunId} from 'sentry/views/seerExplorer/types';
import {getExplorerUrl, getLangfuseUrl} from 'sentry/views/seerExplorer/utils';

import type {AssistantBlockProps} from './shared';
Expand Down Expand Up @@ -53,7 +53,11 @@ export function AssistantBlock({
);
}

function useBlockFeedback(block: Block, blockIndex: number, runId: number | undefined) {
function useBlockFeedback(
block: Block,
blockIndex: number,
runId: SeerExplorerRunId | undefined
) {
const organization = useOrganization();
const [feedbackSubmitted, setFeedbackSubmitted] = useSessionStorage(
`seer-explorer-feedback:run-${runId ?? 'null'}:block-${block.id}`,
Expand Down
4 changes: 2 additions & 2 deletions static/app/views/seerExplorer/components/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {motion} from 'framer-motion';
import {Container} from '@sentry/scraps/layout';

import {unreachable} from 'sentry/utils/unreachable';
import type {Block} from 'sentry/views/seerExplorer/types';
import type {Block, SeerExplorerRunId} from 'sentry/views/seerExplorer/types';

import {AssistantBlock} from './assistant';
import {ToolUseBlock} from './toolUse';
Expand All @@ -18,7 +18,7 @@ interface BlockProps {
onClick?: () => void;
readOnly?: boolean;
ref?: React.Ref<HTMLDivElement>;
runId?: number;
runId?: SeerExplorerRunId;
showThinking?: boolean;
}

Expand Down
4 changes: 2 additions & 2 deletions static/app/views/seerExplorer/components/chat/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {Link} from '@sentry/scraps/link';
import {Markdown, type MarkdownProps} from '@sentry/scraps/markdown';
import {Heading} from '@sentry/scraps/text';

import type {Block} from 'sentry/views/seerExplorer/types';
import type {Block, SeerExplorerRunId} from 'sentry/views/seerExplorer/types';

interface BlockVariantProps {
block: Block;
Expand All @@ -20,7 +20,7 @@ export interface AssistantBlockProps extends BlockVariantProps {
blockIndex: number;
interactionPending?: boolean;
readOnly?: boolean;
runId?: number;
runId?: SeerExplorerRunId;
}

export interface ToolUseBlockProps extends BlockVariantProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {trackAnalytics} from 'sentry/utils/analytics';
import {useOrganization} from 'sentry/utils/useOrganization';
import {ExplorerDrawerContent} from 'sentry/views/seerExplorer/components/drawer/explorerDrawerContent';
import {useSeerExplorerChatDispatch} from 'sentry/views/seerExplorer/seerExplorerChatStateContext';
import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types';
import {isSeerExplorerEnabled, usePageReferrer} from 'sentry/views/seerExplorer/utils';

const SEER_EXPLORER_DRAWER_KEY = 'seer-explorer-drawer';
Expand All @@ -21,7 +22,7 @@ export type OpenSeerExplorerDrawerOptions = {
* Optional run ID to open. If provided, opens an existing session.
* Cannot be used together with `startNewRun`.
*/
runId?: number;
runId?: SeerExplorerRunId;
/**
* If true, switches to a new session before opening.
* Cannot be used together with `runId`.
Expand Down
3 changes: 2 additions & 1 deletion static/app/views/seerExplorer/components/emptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {Flex} from '@sentry/scraps/layout';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
import {IconSeer} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types';

const SUGGESTED_QUESTIONS = [
t('Which of my open issues are getting worse, not better?'),
Expand All @@ -19,7 +20,7 @@ interface EmptyStateProps {
isError?: boolean;
isLoading?: boolean;
onSuggestionClick?: (question: string) => void;
runId?: number | null;
runId?: SeerExplorerRunId | null;
}

export function EmptyState({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import {
import {t} from 'sentry/locale';
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
import {useSeerExplorerSessionsQuery} from 'sentry/views/seerExplorer/seerExplorerSessionContext';
import type {SeerExplorerSidebarPosition} from 'sentry/views/seerExplorer/types';
import type {
SeerExplorerRunId,
SeerExplorerSidebarPosition,
} from 'sentry/views/seerExplorer/types';

const POSITION_ICON_DIRECTION = {
auto: undefined,
Expand All @@ -44,7 +47,7 @@ const POSITION_ICON_DIRECTION = {
interface SeerExplorerHeaderProps {
isPipSupported: boolean;
isPoppedOut: boolean;
onChangeSession: (runId: number) => void;
onChangeSession: (runId: SeerExplorerRunId) => void;
onCopyLinkClick: (() => void) | undefined;
onCopySessionClick: (() => void) | undefined;
onNewChatClick: () => void;
Expand Down Expand Up @@ -92,7 +95,7 @@ export function SeerExplorerHeader({
}
return (
data?.data.map(session => ({
value: session.run_id,
value: session.sentry_run_id ?? session.run_id,
label: session.title,
details: (
<TimeSince
Expand Down
37 changes: 37 additions & 0 deletions static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,43 @@ describe('useSeerExplorer', () => {
expect(result.current.hasSentInterrupt).toBe(false);
});
});

it('URL-encodes the runId when building explorer-update URLs', async () => {
// A runId carrying path separators must be encoded so the same-origin
// POST can't traverse to another endpoint.
const maliciousRunId = '../../foo';
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/seer/explorer-chat/..%2F..%2Ffoo/`,
method: 'GET',
body: {session: {blocks: [], status: 'processing'}},
});
const updateMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/seer/explorer-update/..%2F..%2Ffoo/`,
method: 'POST',
body: {run_id: 123},
});

const {result} = renderHookWithProviders(() => useSeerExplorer(), {
organization,
additionalWrapper: SeerExplorerChatStateProvider,
});

act(() => {
result.current.switchToRun(maliciousRunId);
});

await waitFor(() => {
expect(result.current.runId).toBe(maliciousRunId);
});

act(() => {
result.current.interruptRun();
});

await waitFor(() => {
expect(updateMock).toHaveBeenCalled();
});
});
});

describe('Polling Logic', () => {
Expand Down
53 changes: 36 additions & 17 deletions static/app/views/seerExplorer/hooks/useSeerExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
Block,
RepoPRState,
SeerExplorerResponse,
SeerExplorerRunId,
} from 'sentry/views/seerExplorer/types';
import {
isSeerExplorerEnabled,
Expand All @@ -30,12 +31,21 @@ import {
type SeerExplorerChatResponse = {
message: Block;
run_id: number;
sentry_run_id?: string | null;
};

type SeerExplorerUpdateResponse = {
run_id: number;
};

/**
* Build the explorer-update endpoint URL. `runId` can originate from an
* attacker-controlled `explorerRunId` deep link, so it must be encoded to
* prevent path traversal in the resulting same-origin POST.
*/
const makeExplorerUpdateUrl = (orgSlug: string, runId: SeerExplorerRunId | null) =>
`/organizations/${orgSlug}/seer/explorer-update/${encodeURIComponent(String(runId))}/`;

/** Routes where the LLMContext tree provides structured page context. */
const STRUCTURED_CONTEXT_ROUTES = new Set([
'/dashboard/:dashboardId/',
Expand Down Expand Up @@ -101,7 +111,6 @@ const getOptimisticAssistantTexts = () => [

const makeErrorSeerExplorerData = (errorMessage: string): SeerExplorerResponse => ({
session: {
run_id: undefined,
blocks: [
{
id: 'error',
Expand Down Expand Up @@ -170,7 +179,7 @@ export const useSeerExplorer = () => {
overrideCtxEngEnable: boolean;
pageName: string;
query: string;
runId: number | null;
runId: SeerExplorerRunId | null;
screenshot: string | undefined;
}
>({
Expand Down Expand Up @@ -209,8 +218,11 @@ export const useSeerExplorer = () => {
},
onSuccess: (response, params) => {
if (params.runId === null) {
// set run ID if this is a new session
dispatch({type: 'set run id', payload: response.run_id});
// Prefer the UUID; fall back to the numeric run_id for legacy runs.
dispatch({
type: 'set run id',
payload: response.sentry_run_id ?? response.run_id,
});
} else {
// invalidate the query so fresh data is fetched
queryClient.invalidateQueries({
Expand All @@ -237,13 +249,17 @@ export const useSeerExplorer = () => {
},
});

const {mutate: userInputMutate} = useMutation({
mutationFn: async (params: {
const {mutate: userInputMutate} = useMutation<
SeerExplorerUpdateResponse,
RequestError,
{
inputId: string;
orgSlug: string;
runId: number | null;
runId: SeerExplorerRunId | null;
responseData?: Record<string, unknown>;
}) => {
}
>({
mutationFn: async params => {
setHasSentInterrupt(false);

// Set optimistic status and updated_at to prevent isPolling flicker on new message.
Expand All @@ -264,8 +280,8 @@ export const useSeerExplorer = () => {
: prev
);
}
return fetchMutation<SeerExplorerUpdateResponse>({
url: `/organizations/${params.orgSlug}/seer/explorer-update/${params.runId}/`,
return fetchMutation({
url: makeExplorerUpdateUrl(params.orgSlug, params.runId),
method: 'POST',
data: {
payload: {
Expand Down Expand Up @@ -305,7 +321,7 @@ export const useSeerExplorer = () => {
const {mutate: createPRMutate} = useMutation<
SeerExplorerUpdateResponse,
RequestError,
{orgSlug: string; runId: number | null; repoName?: string}
{orgSlug: string; runId: SeerExplorerRunId | null; repoName?: string}
>({
mutationFn: async params => {
setHasSentInterrupt(false);
Expand All @@ -329,7 +345,7 @@ export const useSeerExplorer = () => {
);
}
return fetchMutation({
url: `/organizations/${params.orgSlug}/seer/explorer-update/${params.runId}/`,
url: makeExplorerUpdateUrl(params.orgSlug, params.runId),
method: 'POST',
data: {
payload: {
Expand Down Expand Up @@ -363,13 +379,13 @@ export const useSeerExplorer = () => {
RequestError,
{
orgSlug: string;
runId: number | null;
runId: SeerExplorerRunId | null;
}
>({
mutationFn: async params => {
setHasSentInterrupt(true);
return fetchMutation({
url: `/organizations/${params.orgSlug}/seer/explorer-update/${params.runId}/`,
url: makeExplorerUpdateUrl(params.orgSlug, params.runId),
method: 'POST',
data: {
payload: {
Expand Down Expand Up @@ -401,7 +417,7 @@ export const useSeerExplorer = () => {

/** Switches to a different run and fetches its latest state. */
const switchToRun = useCallback(
(newRunId: number | null, {onSuccess}: {onSuccess?: () => void} = {}) => {
Comment thread
cursor[bot] marked this conversation as resolved.
(newRunId: SeerExplorerRunId | null, {onSuccess}: {onSuccess?: () => void} = {}) => {
if (newRunId === runId) {
return;
}
Expand Down Expand Up @@ -432,7 +448,11 @@ export const useSeerExplorer = () => {
);

const sendMessage = useCallback(
(query: string, explicitInsertIndex?: number, explicitRunId?: number | null) => {
(
query: string,
explicitInsertIndex?: number,
explicitRunId?: SeerExplorerRunId | null
) => {
if (!orgSlug) {
return;
}
Expand Down Expand Up @@ -639,7 +659,6 @@ export const useSeerExplorer = () => {
];

const baseSession = rawSessionData ?? {
run_id: runId ?? undefined,
blocks: [],
status: 'processing' as const,
updated_at: new Date().toISOString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {useApiQuery} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useTimeout} from 'sentry/utils/useTimeout';
import type {PollingState} from 'sentry/views/seerExplorer/seerExplorerChatStateContext';
import type {SeerExplorerResponse} from 'sentry/views/seerExplorer/types';
import type {
SeerExplorerResponse,
SeerExplorerRunId,
} from 'sentry/views/seerExplorer/types';
import type {Block} from 'sentry/views/seerExplorer/types';
import {
isSeerExplorerEnabled,
Expand Down Expand Up @@ -36,7 +39,7 @@ const getTimestampAge = (updatedAt: string | undefined): number | null => {
};

const getPollingState = (
runId: number | null,
runId: SeerExplorerRunId | null,
sessionData: SeerExplorerResponse['session'] | undefined,
isError: boolean,
errorStatusCode: number | undefined,
Expand Down Expand Up @@ -70,7 +73,7 @@ const getPollingState = (
* Called exclusively by `SeerExplorerChatStateProvider`, which dispatches the
* derived polling state into context for all consumers.
*/
export const useSeerExplorerPolling = ({runId}: {runId: number | null}) => {
export const useSeerExplorerPolling = ({runId}: {runId: SeerExplorerRunId | null}) => {
const organization = useOrganization({allowNull: true});
const orgSlug = organization?.slug;
const errorPollCountRef = useRef(0);
Expand Down
Loading
Loading