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 @@ -136,7 +137,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 @@ -158,7 +159,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';

export type OpenSeerExplorerDrawerOptions = {
Expand All @@ -19,7 +20,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
27 changes: 17 additions & 10 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,6 +31,7 @@ import {
type SeerExplorerChatResponse = {
message: Block;
run_id: number;
sentry_run_id?: string | null;
};

type SeerExplorerUpdateResponse = {
Expand Down Expand Up @@ -101,7 +103,6 @@ const getOptimisticAssistantTexts = () => [

const makeErrorSeerExplorerData = (errorMessage: string): SeerExplorerResponse => ({
session: {
run_id: undefined,
blocks: [
{
id: 'error',
Expand Down Expand Up @@ -170,7 +171,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 +210,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 Down Expand Up @@ -243,7 +247,7 @@ export const useSeerExplorer = () => {
{
inputId: string;
orgSlug: string;
runId: number | null;
runId: SeerExplorerRunId | null;
responseData?: Record<string, any>;
}
>({
Expand Down Expand Up @@ -309,7 +313,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 Down Expand Up @@ -367,7 +371,7 @@ export const useSeerExplorer = () => {
RequestError,
{
orgSlug: string;
runId: number | null;
runId: SeerExplorerRunId | null;
}
>({
mutationFn: async params => {
Expand Down Expand Up @@ -405,7 +409,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 @@ -436,7 +440,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 @@ -643,7 +651,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
15 changes: 8 additions & 7 deletions static/app/views/seerExplorer/seerExplorerChatStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {

import {sessionStorageWrapper} from 'sentry/utils/sessionStorage';
import {useSeerExplorerPolling} from 'sentry/views/seerExplorer/hooks/useSeerExplorerPolling';
import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types';

export type PollingState =
| 'polling'
Expand All @@ -22,24 +23,24 @@ type ChatState = {
};

type SeerExplorerChatState = {
chatStates: Record<number, ChatState>;
runId: number | null;
chatStates: Record<SeerExplorerRunId, ChatState>;
runId: SeerExplorerRunId | null;
};

type ChatStateAction =
| {payload: {polling: PollingState; runId: number}; type: 'set polling'}
| {payload: number | null; type: 'set run id'};
| {payload: {polling: PollingState; runId: SeerExplorerRunId}; type: 'set polling'}
| {payload: SeerExplorerRunId | null; type: 'set run id'};

const RUN_ID_STORAGE_KEY = 'seer-explorer-run-id';

function readRunIdFromStorage(): number | null {
function readRunIdFromStorage(): SeerExplorerRunId | null {
const raw = sessionStorageWrapper.getItem(RUN_ID_STORAGE_KEY);
if (raw === null || raw === 'undefined') {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
return typeof parsed === 'number' ? parsed : null;
return typeof parsed === 'number' || typeof parsed === 'string' ? parsed : null;
} catch {
return null;
}
Expand Down Expand Up @@ -121,7 +122,7 @@ function SeerExplorerChatStatePolling({
}: {
children: ReactNode;
dispatch: Dispatch<ChatStateAction>;
runId: number | null;
runId: SeerExplorerRunId | null;
}) {
const {pollingState} = useSeerExplorerPolling({runId});

Expand Down
4 changes: 3 additions & 1 deletion static/app/views/seerExplorer/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,10 @@
data: Record<string, any>;
id: string;
input_type: 'file_change_approval' | 'ask_user_question';
};

Check notice on line 143 in static/app/views/seerExplorer/types.tsx

View check run for this annotation

@sentry/warden / warden: security-review

Unencoded attacker-controlled `explorerRunId` enables client-side path traversal in update mutation URLs

The `explorerRunId` query param is parsed in `useSeerExplorerDeepLink` and, when non-numeric, passed unchanged into `switchToRun`, setting `runId` to an arbitrary string. The userInput, createPR, and interrupt mutations interpolate `runId` directly into `/organizations/${orgSlug}/seer/explorer-update/${runId}/` without encoding, so a crafted link plus a victim click can issue an authenticated same-origin POST to a traversed API path. Encode `runId` with `encodeURIComponent` (or route these through `getApiUrl` like the polling path).
Comment thread
sentry-warden[bot] marked this conversation as resolved.

export type SeerExplorerRunId = number | string;

export type SeerExplorerResponse = {
session: {
blocks: Block[];
Expand All @@ -150,6 +152,6 @@
owner_user_id?: number | null;
pending_user_input?: PendingUserInput | null;
repo_pr_states?: Record<string, RepoPRState>;
run_id?: number;
} | null;
sentry_run_id?: string | null;
};
3 changes: 2 additions & 1 deletion static/app/views/seerExplorer/useSeerExplorerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from 'sentry/views/seerExplorer/components/drawer/useSeerExplorerDrawer';
import {useSeerExplorerPolling} from 'sentry/views/seerExplorer/hooks/useSeerExplorerPolling';
import {useSeerExplorerChatState} from 'sentry/views/seerExplorer/seerExplorerChatStateContext';
import type {SeerExplorerRunId} from 'sentry/views/seerExplorer/types';
import {useSeerExplorerDeepLink} from 'sentry/views/seerExplorer/utils';

type SeerExplorerSessionState = 'inactive' | 'thinking' | 'done-thinking';
Expand Down Expand Up @@ -152,7 +153,7 @@ export function SeerExplorerContextProvider({children}: {children: ReactNode}) {

// Deep link effect while drawer closed (drawer content handles when open)
const deepLinkCallback = useCallback(
(_runId: number) => openSeerExplorerDrawer({runId: _runId}),
(_runId: SeerExplorerRunId) => openSeerExplorerDrawer({runId: _runId}),
[openSeerExplorerDrawer]
);

Expand Down
19 changes: 10 additions & 9 deletions static/app/views/seerExplorer/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize';
import {makeReplaysPathname} from 'sentry/views/explore/replays/pathnames';
import type {
Block,
SeerExplorerRunId,
ToolCall,
ToolLink,
ToolResult,
Expand All @@ -53,7 +54,7 @@ type ToolFormatter = (

export const makeSeerExplorerQueryKey = (
orgSlug: string,
runId: number | null
runId: SeerExplorerRunId | null
): ApiQueryKey => [
runId
? getApiUrl('/organizations/$organizationIdOrSlug/seer/explorer-chat/$runId/', {
Expand Down Expand Up @@ -1067,7 +1068,7 @@ export function useSeerExplorerDeepLink({
callback,
enabled = true,
}: {
callback: (runId: number) => void;
callback: (runId: SeerExplorerRunId) => void;
enabled?: boolean;
}) {
const location = useLocation();
Expand All @@ -1083,12 +1084,10 @@ export function useSeerExplorerDeepLink({
return;
}

const parsedRunId = Number(paramValue);
if (!Number.isNaN(parsedRunId)) {
const {[RUN_ID_QUERY_PARAM]: _removed, ...restQuery} = location.query ?? {};
navigate({...location, query: restQuery}, {replace: true});
callback(parsedRunId);
}
const {[RUN_ID_QUERY_PARAM]: _removed, ...restQuery} = location.query ?? {};
navigate({...location, query: restQuery}, {replace: true});
const numericRunId = Number(paramValue);
callback(Number.isNaN(numericRunId) ? paramValue : numericRunId);
}, [location, navigate, callback, enabled]);
}

Expand All @@ -1105,7 +1104,9 @@ export function getLangfuseUrl(runId: number | string): string {
return `https://langfuse.getsentry.net/project/clx9kma1k0001iebwrfw4oo0z/sessions/${runId}`;
}

export function getExplorerFeedbackOptions(runId: number | null): UseFeedbackOptions {
export function getExplorerFeedbackOptions(
runId: SeerExplorerRunId | null
): UseFeedbackOptions {
return {
formTitle: 'Seer Agent Feedback',
messagePlaceholder: 'How can we make Seer better for you?',
Expand Down
Loading