diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 62c1d09..565e122 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -28,7 +28,7 @@ }, "devDependencies": { "@caido/sdk-backend": "0.55.3", - "@caido/sdk-frontend": "0.55.3", + "@caido/sdk-frontend": "0.56.3-beta.0", "@codemirror/view": "6.39.16", "vue-tsc": "3.2.5" } diff --git a/packages/frontend/src/agent/collection/index.ts b/packages/frontend/src/agent/collection/index.ts index f3f533c..c4750f4 100644 --- a/packages/frontend/src/agent/collection/index.ts +++ b/packages/frontend/src/agent/collection/index.ts @@ -61,7 +61,7 @@ function showLaunchDialog(sdk: FrontendSDK, sessionId: string, preSelectedSkillI dialog = sdk.window.showDialog( { - component: LaunchDialog, + component: LaunchDialog as never, props: { onConfirm: () => handleConfirm, onCancel: () => handleCancel, diff --git a/packages/frontend/src/agent/tools/findings/FindingsCreate.ts b/packages/frontend/src/agent/tools/findings/FindingsCreate.ts index afc894b..9b6c490 100644 --- a/packages/frontend/src/agent/tools/findings/FindingsCreate.ts +++ b/packages/frontend/src/agent/tools/findings/FindingsCreate.ts @@ -73,7 +73,10 @@ export const FindingsCreate = tool({ return ToolResult.err("No active entry found in replay session"); } - const requestId = activeEntry.request?.id; + const requestId = + activeEntry.__typename === "ReplayEntryWs" + ? activeEntry.http.request?.id + : activeEntry.request?.id; if (requestId === undefined || requestId === null || requestId === "") { return ToolResult.err("Request ID not found in active entry"); } diff --git a/packages/frontend/src/agent/tools/history/HistorySearch.ts b/packages/frontend/src/agent/tools/history/HistorySearch.ts index 0b2c0c9..7391953 100644 --- a/packages/frontend/src/agent/tools/history/HistorySearch.ts +++ b/packages/frontend/src/agent/tools/history/HistorySearch.ts @@ -108,7 +108,7 @@ Returns request metadata (id, host, port, path, query, method, isTls, createdAt, const graphqlResult = await safeGraphQL( () => context.sdk.graphql.requestsByOffset({ - filter, + filter: { code: filter }, limit, offset, order: { diff --git a/packages/frontend/src/agent/tools/replay/ReplayEntryNavigate.ts b/packages/frontend/src/agent/tools/replay/ReplayEntryNavigate.ts index 04b8c56..e730b2f 100644 --- a/packages/frontend/src/agent/tools/replay/ReplayEntryNavigate.ts +++ b/packages/frontend/src/agent/tools/replay/ReplayEntryNavigate.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { AgentContext } from "@/agent/context"; import { type ToolDisplay, ToolResult, type ToolResult as ToolResultType } from "@/agent/types"; -import { isPresent } from "@/utils/optional"; +import { getReplayEntryRequest } from "@/utils/caido"; const inputSchema = z.object({ entryId: z @@ -16,6 +16,7 @@ const inputSchema = z.object({ const valueSchema = z.object({ entryId: z.string(), requestId: z.string().optional(), + requestRaw: z.string().optional(), }); const outputSchema = ToolResult.schema(valueSchema); @@ -46,33 +47,26 @@ export const ReplayEntryNavigate = tool({ const context = experimental_context as AgentContext; const sdk = context.sdk; - const entry = sdk.replay.getEntry(entryId); - if (!isPresent(entry)) { - return ToolResult.err("Entry not found"); + const entryResult = await getReplayEntryRequest(sdk, entryId, context.sessionId); + if (entryResult.kind === "Error") { + return ToolResult.err("Entry not found", entryResult.error); } - if (entry.sessionId !== context.sessionId) { + const { entry, request } = entryResult.value; + + if (entry.session.id !== context.sessionId) { return ToolResult.err("Entry does not belong to the current session"); } - await sdk.replay.showEntry(context.sessionId, entryId, { - overwriteDraft: true, - }); + await sdk.replay.showEntry(context.sessionId, entryId); - const entryResult = await sdk.graphql.replayEntry({ - id: entryId, - }); - const replayEntry = entryResult.replayEntry; - - if (isPresent(replayEntry)) { - context.setHttpRequest(replayEntry.raw); - } + context.setHttpRequest(entry.raw); return ToolResult.ok({ message: `Navigated to entry ${entryId}`, entryId, - requestId: entry.requestId, - requestRaw: replayEntry?.raw, + requestId: request?.id, + requestRaw: entry.raw, }); } catch (error) { return ToolResult.err("Failed to navigate to entry", (error as Error).message); diff --git a/packages/frontend/src/agent/tools/request/RequestSend.ts b/packages/frontend/src/agent/tools/request/RequestSend.ts index c2331dc..0312325 100644 --- a/packages/frontend/src/agent/tools/request/RequestSend.ts +++ b/packages/frontend/src/agent/tools/request/RequestSend.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { AgentContext } from "@/agent/context"; import { type ToolDisplay, ToolResult, type ToolResult as ToolResultType } from "@/agent/types"; -import { getReplaySession } from "@/utils/caido"; +import { getReplaySession, usesReplayEntryInterface, writeToRequestEditor } from "@/utils/caido"; import { formatStringWithSuffix } from "@/utils/text"; const inputSchema = z.object({}); @@ -21,6 +21,34 @@ type RequestSendInput = z.infer; type RequestSendValue = z.infer; type RequestSendOutput = ToolResultType; +type LegacySendRequestOptions = { + background?: boolean; + connectionInfo: { + host: string; + isTLS: boolean; + port: number; + SNI?: string; + }; + raw: string; +}; + +type LegacySendRequest = (sessionId: string, options: LegacySendRequestOptions) => Promise; + +const getActiveEntryResponseId = async ( + sdk: AgentContext["sdk"], + sessionId: string +): Promise => { + const result = await sdk.graphql.replaySessionEntries({ id: sessionId }); + const activeEntry = result.replaySession?.activeEntry; + + return activeEntry?.__typename === "ReplayEntryWs" + ? activeEntry.http.request?.response?.id + : activeEntry?.request?.response?.id; +}; + +const sleep = (duration: number): Promise => + new Promise((resolve) => setTimeout(resolve, duration)); + export const formatRequestSendModelOutput = (value: { message: string; rawResponse: string; @@ -113,17 +141,47 @@ export const RequestSend = tool({ const replaySession = replaySessionResult.value; - await sdk.replay.sendRequest(replaySession.id, { - connectionInfo: { - host: replaySession.request.host, - isTLS: replaySession.request.isTLS, - port: replaySession.request.port, - }, - raw: context.httpRequest, - background: true, - }); + const previousResponseId = await getActiveEntryResponseId(sdk, replaySession.id); let responseId: string | undefined = undefined; + + const captureResponseId = (nextResponseId: string | undefined): boolean => { + if (nextResponseId !== undefined && nextResponseId !== previousResponseId) { + responseId = nextResponseId; + return true; + } + + return false; + }; + + try { + if (usesReplayEntryInterface(sdk)) { + writeToRequestEditor(context.httpRequest); + await sdk.replay.sendRequest(replaySession.id, { + background: false, + }); + } else { + if (replaySession.request.host === "" || replaySession.request.port === 0) { + return ToolResult.err("No active replay entry to send"); + } + + const sendRequest = sdk.replay.sendRequest as unknown as LegacySendRequest; + await sendRequest(replaySession.id, { + background: false, + connectionInfo: { + host: replaySession.request.host, + isTLS: replaySession.request.isTLS, + port: replaySession.request.port, + SNI: replaySession.request.SNI || undefined, + }, + raw: context.httpRequest, + }); + } + } catch (error) { + const detail = error instanceof Error ? error.message : "Unknown error occurred"; + return ToolResult.err("Failed to send request", detail); + } + const iterator = sdk.graphql.updatedReplaySession({}); const timeout = new Promise((_, reject) => { @@ -144,15 +202,31 @@ export const RequestSend = tool({ const responsePromise = (async () => { for await (const event of iterator) { if (event.updatedReplaySession.sessionEdge.node.id === replaySession.id) { - responseId = - event.updatedReplaySession.sessionEdge.node.activeEntry?.request?.response?.id; + const activeEntry = event.updatedReplaySession.sessionEdge.node.activeEntry; + const nextResponseId = + activeEntry?.__typename === "ReplayEntryWs" + ? activeEntry.http.request?.response?.id + : activeEntry?.request?.response?.id; + + if (captureResponseId(nextResponseId ?? undefined)) { + break; + } + } + } + })(); + + const pollResponsePromise = (async () => { + while (responseId === undefined) { + if (captureResponseId(await getActiveEntryResponseId(sdk, replaySession.id))) { break; } + + await sleep(250); } })(); try { - await Promise.race([responsePromise, timeout, abortPromise]); + await Promise.race([responsePromise, pollResponsePromise, timeout, abortPromise]); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw error; diff --git a/packages/frontend/src/float/actions/automate/AutomateSessionCreate.ts b/packages/frontend/src/float/actions/automate/AutomateSessionCreate.ts index c06c32f..8e6122e 100644 --- a/packages/frontend/src/float/actions/automate/AutomateSessionCreate.ts +++ b/packages/frontend/src/float/actions/automate/AutomateSessionCreate.ts @@ -163,6 +163,7 @@ export const automateSessionCreateTool = tool({ } const graphPayloads = attachDefaultPreprocessors((payloads ?? []).map(toGraphQLPayload)); + const { extractors, ...settings } = session.settings; await sdk.graphql.updateAutomateSession({ id: session.id, @@ -174,9 +175,16 @@ export const automateSessionCreateTool = tool({ }, raw: session.raw, settings: { - ...session.settings, + ...settings, strategy: strategy ?? "ALL", concurrency: concurrency ?? { delay: 0, workers: 10 }, + extractors: extractors.map(({ body, regex, workflowId }) => ({ + automateExtractorRegex: { + body, + regex, + workflowId, + }, + })), payloads: graphPayloads, placeholders, }, diff --git a/packages/frontend/src/float/actions/history/HistoryRead.ts b/packages/frontend/src/float/actions/history/HistoryRead.ts index 9d3bd84..d8021ca 100644 --- a/packages/frontend/src/float/actions/history/HistoryRead.ts +++ b/packages/frontend/src/float/actions/history/HistoryRead.ts @@ -158,7 +158,7 @@ export const historyReadTool = tool({ try { const result = await sdk.graphql.interceptEntriesByOffset({ - filter, + filter: filter !== undefined ? { code: filter } : undefined, limit, offset, scopeId: effectiveScopeId, diff --git a/packages/frontend/src/float/actions/replay/ReplaySessionCreate.ts b/packages/frontend/src/float/actions/replay/ReplaySessionCreate.ts index f3a62ff..b7029fb 100644 --- a/packages/frontend/src/float/actions/replay/ReplaySessionCreate.ts +++ b/packages/frontend/src/float/actions/replay/ReplaySessionCreate.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { normalizeRawHttpRequest } from "@/agent/utils/http"; import { ActionResult, type FloatToolContext } from "@/float/types"; +import { type FrontendSDK } from "@/types"; +import { usesReplayEntryInterface } from "@/utils/caido"; const inputSchema = z.object({ rawRequest: z.string().describe("Raw HTTP request source (non-empty)"), @@ -21,19 +23,32 @@ export const replaySessionCreateTool = tool({ outputSchema: ActionResult.schema, execute: async ({ rawRequest, host, port, isTls, sessionName }, { experimental_context }) => { const { sdk } = experimental_context as FloatToolContext; - const result = await sdk.graphql.createReplaySession({ - input: { - requestSource: { - raw: { - raw: normalizeRawHttpRequest(rawRequest), - connectionInfo: { - host, - port, - isTLS: isTls, - }, - }, + type CreateReplaySessionVariables = Parameters< + FrontendSDK["graphql"]["createReplaySession"] + >[0]; + const requestSource = { + raw: { + raw: normalizeRawHttpRequest(rawRequest), + connectionInfo: { + host, + port, + isTLS: isTls, }, }, + }; + const input = ( + usesReplayEntryInterface(sdk) + ? { + kind: "HTTP", + requestSource, + } + : { + requestSource, + } + ) as CreateReplaySessionVariables["input"]; + + const result = await sdk.graphql.createReplaySession({ + input, }); const sessionId = result.createReplaySession.session?.id; diff --git a/packages/frontend/src/float/utils.ts b/packages/frontend/src/float/utils.ts index 3740fea..d800b5f 100644 --- a/packages/frontend/src/float/utils.ts +++ b/packages/frontend/src/float/utils.ts @@ -160,7 +160,7 @@ const showConfirmationDialog = ( ) => { const dialog = sdk.window.showDialog( { - component: ConfirmationDialog, + component: ConfirmationDialog as never, props: { fileName, content, diff --git a/packages/frontend/src/renaming/ai.ts b/packages/frontend/src/renaming/ai.ts index 0222bdb..18bf7d9 100644 --- a/packages/frontend/src/renaming/ai.ts +++ b/packages/frontend/src/renaming/ai.ts @@ -1,4 +1,3 @@ -import { type ReplayEntryQuery } from "@caido/sdk-frontend/src/types/__generated__/graphql-sdk"; import { generateText, Output } from "ai"; import { Result } from "shared"; import { z } from "zod"; @@ -7,6 +6,7 @@ import { useModelsStore } from "@/stores/models"; import { useSettingsStore } from "@/stores/settings"; import { type FrontendSDK } from "@/types"; import { createModel, resolveModel } from "@/utils"; +import { type ReplayEntryWithRequest } from "@/utils/caido"; const outputSchema = z.object({ name: z.string(), @@ -66,7 +66,7 @@ OUTPUT: export async function generateName( sdk: FrontendSDK, - entry: ReplayEntryQuery + entry: ReplayEntryWithRequest ): Promise> { try { const settingsStore = useSettingsStore(); @@ -88,7 +88,7 @@ export async function generateName( const prompt = ` - ${JSON.stringify(entry)} + ${JSON.stringify(entry.entry)} diff --git a/packages/frontend/src/renaming/index.ts b/packages/frontend/src/renaming/index.ts index 5c968a2..68d5524 100644 --- a/packages/frontend/src/renaming/index.ts +++ b/packages/frontend/src/renaming/index.ts @@ -6,13 +6,22 @@ import { import { generateName } from "@/renaming/ai"; import { useSettingsStore } from "@/stores/settings"; import { type FrontendSDK } from "@/types"; +import { getReplayEntryRequest } from "@/utils/caido"; import { isPresent } from "@/utils/optional"; export const setupRenaming = (sdk: FrontendSDK) => { const settingsStore = useSettingsStore(); const renameSession = async (entryId: string, sessionId: string) => { - const nameResult = await generateName(sdk, await sdk.graphql.replayEntry({ id: entryId })); + const entryResult = await getReplayEntryRequest(sdk, entryId, sessionId); + if (entryResult.kind === "Error") { + sdk.window.showToast(`[Shift] Failed while fetching replay entry: ${entryResult.error}`, { + variant: "error", + }); + return; + } + + const nameResult = await generateName(sdk, entryResult.value); if (nameResult.kind === "Error") { sdk.window.showToast(`[Shift] Failed while renaming session: ${nameResult.error}`, { variant: "error", diff --git a/packages/frontend/src/utils/caido.ts b/packages/frontend/src/utils/caido.ts index 49d354d..c881a00 100644 --- a/packages/frontend/src/utils/caido.ts +++ b/packages/frontend/src/utils/caido.ts @@ -36,6 +36,180 @@ type ReplaySession = { }; }; +type ReplayEntryHttp = { + __typename: "ReplayEntry" | "ReplayEntryHttp"; + id: string; + raw: string; + connection: { + host: string; + port: number; + isTLS: boolean; + SNI?: string; + }; + session: { + id: string; + }; + request?: { + id: string; + response?: { + id: string; + }; + }; +}; + +type ReplayEntryHttpInput = Omit & { + connection: Omit & { + SNI?: unknown; + }; + request?: unknown; +}; + +type LegacyReplayEntryQuery = { + replayEntry?: ReplayEntryHttp; +}; + +type LegacyReplayEntryVariables = { + id: string; +}; + +type LegacyReplayEntryFn = ( + variables: LegacyReplayEntryVariables +) => Promise; + +type ReplaySessionEntriesResult = Awaited< + ReturnType +>; +type ReplaySessionEntry = NonNullable< + NonNullable["activeEntry"] +>; +type ReplaySessionEntryLike = ReplaySessionEntry; + +export type ReplayEntryWithRequest = { + entry: ReplayEntryHttp; + request: ReplayEntryHttp["request"]; +}; + +const getMajorMinorPatch = (version: string): [number, number, number] => { + const [major = "0", minor = "0", patch = "0"] = version.split(/[.-]/); + return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0]; +}; + +const isVersionAtLeast = (version: string, minimum: [number, number, number]): boolean => { + const current = getMajorMinorPatch(version); + + for (let index = 0; index < minimum.length; index++) { + const currentPart = current[index] ?? 0; + const minimumPart = minimum[index] ?? 0; + if (currentPart > minimumPart) return true; + if (currentPart < minimumPart) return false; + } + + return true; +}; + +export const usesReplayEntryInterface = (sdk: FrontendSDK): boolean => + isVersionAtLeast(sdk.runtime.version, [0, 57, 0]); + +const normalizeReplayEntryHttp = (entry: ReplayEntryHttpInput): ReplayEntryHttp => { + let request: ReplayEntryHttp["request"] = undefined; + if (typeof entry.request === "object" && isPresent(entry.request)) { + const entryRequest = entry.request as { id?: unknown; response?: unknown }; + let response: { id: string } | undefined = undefined; + + if (typeof entryRequest.response === "object" && isPresent(entryRequest.response)) { + const entryResponse = entryRequest.response as { id?: unknown }; + if (typeof entryResponse.id === "string") { + response = { id: entryResponse.id }; + } + } + + if (typeof entryRequest.id === "string") { + request = { + id: entryRequest.id, + response, + }; + } + } + + return { + ...entry, + connection: { + ...entry.connection, + SNI: typeof entry.connection.SNI === "string" ? entry.connection.SNI : undefined, + }, + request, + }; +}; + +const toSessionReplayEntryHttp = (entry: ReplaySessionEntryLike): ReplayEntryHttp => + normalizeReplayEntryHttp(entry.__typename === "ReplayEntryWs" ? entry.http : entry); + +const getConcreteReplayEntry = async ( + sdk: FrontendSDK, + entryId: string +): Promise> => { + const replayEntry = sdk.graphql.replayEntry as unknown as LegacyReplayEntryFn; + const result = await safeGraphQL( + () => replayEntry({ id: entryId }), + "Failed to fetch replay entry" + ); + + if (result.kind === "Error") { + return result; + } + + const entry = result.value.replayEntry; + if (!isPresent(entry)) { + return Result.err("Replay entry not found"); + } + + const normalizedEntry = normalizeReplayEntryHttp(entry); + return Result.ok({ entry: normalizedEntry, request: normalizedEntry.request }); +}; + +const getInterfaceReplayEntry = async ( + sdk: FrontendSDK, + sessionId: string, + entryId: string +): Promise> => { + const result = await safeGraphQL( + () => sdk.graphql.replaySessionEntries({ id: sessionId }), + "Failed to fetch replay session entries" + ); + + if (result.kind === "Error") { + return result; + } + + const session = result.value.replaySession; + if (!isPresent(session)) { + return Result.err("Replay session not found"); + } + + const selectedEntry = + (session.activeEntry?.id === entryId ? session.activeEntry : undefined) ?? + session.entries.edges.find((e) => e.node.id === entryId)?.node; + + if (!isPresent(selectedEntry)) { + return Result.err("Replay entry not found"); + } + + const entry = toSessionReplayEntryHttp(selectedEntry); + return Result.ok({ entry, request: entry.request }); +}; + +export const getReplayEntryRequest = async ( + sdk: FrontendSDK, + entryId: string, + sessionId: string +): Promise> => { + if (usesReplayEntryInterface(sdk)) { + return getInterfaceReplayEntry(sdk, sessionId, entryId); + } + + return getConcreteReplayEntry(sdk, entryId); +}; + const getRequestEditorRequestID = (): Result => { const requestEditor = document.querySelector("[data-language=http-request]") as | EditorElement @@ -82,7 +256,12 @@ export const getCurrentRequestID = async (sdk: FrontendSDK): Promise