@gpzhang2001/sharpkit-proxy 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Caido HTTP/GraphQL client — TS port of the CLIENT half of strix
3
+ * tools/proxy/caido_api.py against the HOST-side published endpoint (global
4
+ * fetch + Bearer token from the sandbox session's bootstrap). Documents are
5
+ * trimmed, schema-valid subsets of the caido_sdk_client generated operations
6
+ * (Requests/Request/ReplayEntry/StartReplayTask/sitemap trio). The replay
7
+ * flow adapts the SDK's subscription wait into bounded polling of the entry
8
+ * (finished when a response or an error appears; 30s strix dispatch budget).
9
+ * @module @gpzhang2001/sharpkit-proxy/client
10
+ */
11
+
12
+ import { Buffer } from 'node:buffer'
13
+ import { buildRawRequest, fullUrlFromComponents, applyModifications, parseRawRequest, parseRawResponse, type RawResponseParts } from './replay.ts'
14
+
15
+ /** Which half of a captured exchange to surface (strix RequestPart). */
16
+ export type RequestPart = 'request' | 'response'
17
+
18
+ /** Sort keys accepted by list_requests (strix SortBy). */
19
+ export type SortBy = 'timestamp' | 'host' | 'method' | 'path' | 'status_code' | 'response_time' | 'response_size' | 'source'
20
+
21
+ /** strix `_REQ_FIELD_MAP` resolved to Caido's RequestResponseOrderBy enums. */
22
+ const SORT_ENUMS: Record<SortBy, string> = {
23
+ timestamp: 'CREATED_AT',
24
+ host: 'HOST',
25
+ method: 'METHOD',
26
+ path: 'PATH',
27
+ source: 'SOURCE',
28
+ status_code: 'RESP_STATUS_CODE',
29
+ response_time: 'RESP_ROUNDTRIP_TIME',
30
+ response_size: 'RESP_LENGTH',
31
+ }
32
+
33
+ /** The one Caido port the sandbox publishes (protocol constant). */
34
+ export const CAIDO_PORT = 48080
35
+
36
+ /** Compact Requests query (subset of the SDK's generated document). */
37
+ const REQUESTS_DOC = `query Requests($first: Int, $after: String, $filter: HTTPQLInput, $order: RequestResponseOrderInput, $scopeId: ID, $includeRequestRaw: Boolean!, $includeResponseRaw: Boolean!) {
38
+ requests(first: $first, after: $after, filter: $filter, order: $order, scopeId: $scopeId) {
39
+ edges { cursor node {
40
+ id host port method path query isTls createdAt
41
+ raw @include(if: $includeRequestRaw)
42
+ response { id statusCode roundtripTime length createdAt raw @include(if: $includeResponseRaw) }
43
+ } }
44
+ pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
45
+ }
46
+ }`
47
+
48
+ /** Compact Request query (both raws always requested — SDK parity note). */
49
+ const REQUEST_DOC = `query Request($id: ID!, $includeRequestRaw: Boolean!, $includeResponseRaw: Boolean!) {
50
+ request(id: $id) {
51
+ id host port method path query isTls createdAt
52
+ raw @include(if: $includeRequestRaw)
53
+ response { id statusCode roundtripTime length createdAt raw @include(if: $includeResponseRaw) }
54
+ }
55
+ }`
56
+
57
+ /** Empty replay session create (avoids the double history row the raw-create seeds). */
58
+ const CREATE_REPLAY_SESSION_DOC = `mutation CreateReplaySession($input: CreateReplaySessionInput!) {
59
+ createReplaySession(input: $input) { session { id } error { __typename } }
60
+ }`
61
+
62
+ /** Replay dispatch (strix `replay_send_raw` via the SDK's StartReplayTask). */
63
+ const START_REPLAY_TASK_DOC = `mutation StartReplayTask($sessionId: ID!, $input: StartReplayTaskInput!) {
64
+ startReplayTask(sessionId: $sessionId, input: $input) { error { __typename } task { id replayEntry { id } } }
65
+ }`
66
+
67
+ /** Replay entry poll — finished when request.response or error appears. */
68
+ const REPLAY_ENTRY_DOC = `query ReplayEntry($id: ID!, $includeReplayRaw: Boolean!, $includeRequestRaw: Boolean!, $includeResponseRaw: Boolean!) {
69
+ replayEntry(id: $id) {
70
+ id error
71
+ request { id method path response { id statusCode length roundtripTime raw @include(if: $includeResponseRaw) } }
72
+ }
73
+ }`
74
+
75
+ /** Scope management (strix caido_api.py scope_* via the SDK's ScopeFull set). */
76
+ const SCOPES_DOC = `query Scopes { scopes { id name allowlist denylist indexed } }`
77
+
78
+ const SCOPE_DOC = `query Scope($id: ID!) { scope(id: $id) { id name allowlist denylist indexed } }`
79
+
80
+ const CREATE_SCOPE_DOC = `mutation CreateScope($input: CreateScopeInput!) { createScope(input: $input) { error { __typename } scope { id name allowlist denylist indexed } } }`
81
+
82
+ const UPDATE_SCOPE_DOC = `mutation UpdateScope($id: ID!, $input: UpdateScopeInput!) { updateScope(id: $id, input: $input) { error { __typename } scope { id name allowlist denylist indexed } } }`
83
+
84
+ const DELETE_SCOPE_DOC = `mutation DeleteScope($id: ID!) { deleteScope(id: $id) { deletedId } }`
85
+
86
+ /** Sitemap queries (verbatim field sets from strix caido_api.py:555-592). */
87
+ const SITEMAP_ROOTS_DOC = `query GetSitemapRoots($scopeId: ID) {
88
+ sitemapRootEntries(scopeId: $scopeId) {
89
+ edges { node {
90
+ id kind label hasDescendants
91
+ metadata { ... on SitemapEntryMetadataDomain { isTls port } }
92
+ request { method path response { statusCode } }
93
+ } }
94
+ count { value }
95
+ }
96
+ }`
97
+
98
+ const SITEMAP_DESCENDANTS_DOC = `query GetSitemapDescendants($parentId: ID!, $depth: SitemapDescendantsDepth!) {
99
+ sitemapDescendantEntries(parentId: $parentId, depth: $depth) {
100
+ edges { node {
101
+ id kind label hasDescendants
102
+ request { method path response { statusCode } }
103
+ } }
104
+ count { value }
105
+ }
106
+ }`
107
+
108
+ const SITEMAP_ENTRY_DOC = `query GetSitemapEntry($id: ID!) {
109
+ sitemapEntry(id: $id) {
110
+ id kind label hasDescendants
111
+ metadata { ... on SitemapEntryMetadataDomain { isTls port } }
112
+ request { method path response { statusCode length roundtripTime } }
113
+ requests(first: 30, order: {by: CREATED_AT, ordering: DESC}) {
114
+ edges { node { method path response { statusCode length } } }
115
+ count { value }
116
+ }
117
+ }
118
+ }`
119
+
120
+ /** Fetch contract (global fetch shape) for the GraphQL endpoint. */
121
+ export interface CaidoFetchFn {
122
+ (url: string, init: { readonly method: 'POST'; readonly headers: Readonly<Record<string, string>>; readonly body: string; readonly signal: AbortSignal }): Promise<{ readonly status: number; readonly text: () => Promise<string> }>
123
+ }
124
+
125
+ /** One captured request entry projected for the model (strix shape). */
126
+ export interface RequestListEntry {
127
+ readonly cursor: string
128
+ readonly request: {
129
+ readonly id: string
130
+ readonly host: string
131
+ readonly port: number
132
+ readonly method: string
133
+ readonly path: string
134
+ readonly query: string | null
135
+ readonly tls: boolean
136
+ readonly createdAt: string
137
+ }
138
+ readonly response: { readonly id: string; readonly statusCode: number | null; readonly length: number | null; readonly createdAt: string | null } | null
139
+ }
140
+
141
+ /** PageInfo projection (strix page_info). */
142
+ export interface CaidoPageInfo {
143
+ readonly hasNextPage: boolean
144
+ readonly hasPreviousPage: boolean
145
+ readonly startCursor: string | null
146
+ readonly endCursor: string | null
147
+ }
148
+
149
+ /** The raw halves of one stored request. */
150
+ export interface StoredRequest {
151
+ readonly id: string
152
+ readonly host: string
153
+ readonly port: number
154
+ readonly method: string
155
+ readonly tls: boolean
156
+ readonly requestRaw: string | null
157
+ readonly responseRaw: string | null
158
+ }
159
+
160
+ /** Replay outcome (strix `_format_replay_tool_result` inputs). */
161
+ export interface ReplayOutcome {
162
+ readonly sessionId: string
163
+ readonly status: string
164
+ readonly elapsedMs: number
165
+ readonly error?: string
166
+ readonly response: RawResponseParts | null
167
+ }
168
+
169
+ /** Endpoint coordinates handed over by the sandbox session's bootstrap. */
170
+ export interface CaidoClientOptions {
171
+ readonly baseUrl: string
172
+ readonly token: string
173
+ readonly fetchFn?: CaidoFetchFn
174
+ }
175
+
176
+ /** Minimal GraphQL POST with bearer auth and error normalization. */
177
+ async function graphql<T>(
178
+ options: CaidoClientOptions,
179
+ doc: string,
180
+ variables: Record<string, unknown>,
181
+ signal: AbortSignal,
182
+ ): Promise<T> {
183
+ const fetchFn = options.fetchFn ?? fetch
184
+ const response = await fetchFn(`${options.baseUrl}/graphql`, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${options.token}` },
187
+ body: JSON.stringify({ query: doc, variables }),
188
+ signal,
189
+ })
190
+ const text = await response.text()
191
+ if (response.status !== 200) throw new Error(`caido graphql HTTP ${response.status}: ${text.slice(0, 200)}`)
192
+ let payload: unknown
193
+ try {
194
+ payload = JSON.parse(text)
195
+ } catch (error) {
196
+ throw new Error(`caido graphql unparseable response: ${String(error)}`)
197
+ }
198
+ const record = payload as { readonly data?: unknown; readonly errors?: unknown }
199
+ if (record.errors !== undefined && record.errors !== null) {
200
+ throw new Error(`caido graphql errors: ${JSON.stringify(record.errors).slice(0, 300)}`)
201
+ }
202
+ if (typeof record.data !== 'object' || record.data === null) {
203
+ throw new Error('caido graphql carried no data')
204
+ }
205
+ return record.data as T
206
+ }
207
+
208
+ /** Project one raw requests-connection edge (strix list_requests mapping). */
209
+ function projectEdge(edge: unknown): RequestListEntry {
210
+ const node = (edge as { node: Record<string, unknown> }).node
211
+ const response = node.response as Record<string, unknown> | null
212
+ return {
213
+ cursor: String((edge as { cursor: unknown }).cursor),
214
+ request: {
215
+ id: String(node.id),
216
+ host: String(node.host),
217
+ port: Number(node.port),
218
+ method: String(node.method),
219
+ path: String(node.path),
220
+ query: node.query === null || node.query === undefined ? null : String(node.query),
221
+ tls: node.isTls === true,
222
+ createdAt: new Date(Number(node.createdAt)).toISOString(),
223
+ },
224
+ response: response === null || response === undefined ? null : {
225
+ id: String(response.id),
226
+ statusCode: response.statusCode === null || response.statusCode === undefined ? null : Number(response.statusCode),
227
+ length: response.length === null || response.length === undefined ? null : Number(response.length),
228
+ createdAt: response.createdAt === null || response.createdAt === undefined ? null : new Date(Number(response.createdAt)).toISOString(),
229
+ },
230
+ }
231
+ }
232
+
233
+ /**
234
+ * The Caido client: five operations behind the five proxy tools. All methods
235
+ * take an AbortSignal (tool exec.signal parity).
236
+ */
237
+ export class CaidoClient {
238
+ private readonly options: CaidoClientOptions
239
+
240
+ constructor(options: CaidoClientOptions) {
241
+ this.options = options
242
+ }
243
+
244
+ /** List captured requests with HTTPQL filter/cursor/sort/scope (strix list_requests_with_client). */
245
+ async listRequests(options: {
246
+ readonly httpqlFilter?: string | undefined
247
+ readonly first?: number | undefined
248
+ readonly after?: string | undefined
249
+ readonly sortBy?: SortBy | undefined
250
+ readonly sortOrder?: 'asc' | 'desc' | undefined
251
+ readonly scopeId?: string | undefined
252
+ }, signal: AbortSignal): Promise<{ readonly entries: RequestListEntry[]; readonly pageInfo: CaidoPageInfo }> {
253
+ const data = await graphql<{ requests: { edges: unknown[]; pageInfo: Record<string, unknown> } }>(
254
+ this.options,
255
+ REQUESTS_DOC,
256
+ {
257
+ first: options.first ?? 50,
258
+ ...(options.after !== undefined && options.after !== '' ? { after: options.after } : {}),
259
+ ...(options.httpqlFilter !== undefined && options.httpqlFilter !== '' ? { filter: { code: options.httpqlFilter } } : {}),
260
+ order: { by: SORT_ENUMS[options.sortBy ?? 'timestamp'], ordering: (options.sortOrder ?? 'desc') === 'asc' ? 'ASC' : 'DESC' },
261
+ ...(options.scopeId !== undefined && options.scopeId !== '' ? { scopeId: options.scopeId } : {}),
262
+ includeRequestRaw: false,
263
+ includeResponseRaw: false,
264
+ },
265
+ signal,
266
+ )
267
+ return {
268
+ entries: data.requests.edges.map(projectEdge),
269
+ pageInfo: {
270
+ hasNextPage: data.requests.pageInfo.hasNextPage === true,
271
+ hasPreviousPage: data.requests.pageInfo.hasPreviousPage === true,
272
+ startCursor: data.requests.pageInfo.startCursor === null || data.requests.pageInfo.startCursor === undefined ? null : String(data.requests.pageInfo.startCursor),
273
+ endCursor: data.requests.pageInfo.endCursor === null || data.requests.pageInfo.endCursor === undefined ? null : String(data.requests.pageInfo.endCursor),
274
+ },
275
+ }
276
+ }
277
+
278
+ /** Fetch one request with both raw halves (strix get_request_with_client parity: always request both). */
279
+ async getRequest(requestId: string, signal: AbortSignal): Promise<StoredRequest | null> {
280
+ const data = await graphql<{ request: Record<string, unknown> | null }>(
281
+ this.options,
282
+ REQUEST_DOC,
283
+ { id: requestId, includeRequestRaw: true, includeResponseRaw: true },
284
+ signal,
285
+ )
286
+ const request = data.request
287
+ if (request === null || request === undefined) return null
288
+ return {
289
+ id: String(request.id),
290
+ host: String(request.host),
291
+ port: Number(request.port),
292
+ method: String(request.method),
293
+ tls: request.isTls === true,
294
+ requestRaw: request.raw === null || request.raw === undefined ? null : Buffer.from(String(request.raw), 'base64').toString('utf8'),
295
+ responseRaw: (request.response as Record<string, unknown> | null | undefined)?.raw === null
296
+ || (request.response as Record<string, unknown> | null | undefined)?.raw === undefined
297
+ ? null
298
+ : Buffer.from(String((request.response as Record<string, unknown>).raw), 'base64').toString('utf8'),
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Replay one stored request with optional field patches (strix
304
+ * `repeat_request` + `replay_send_raw`): fetch raw → parse → patch →
305
+ * rebuild → create empty session → startReplayTask → poll the entry.
306
+ * @param requestId - the stored request id.
307
+ * @param modifications - patch dict (url/params/headers/body/cookies).
308
+ * @param budget - total dispatch deadline (strix 30s) + poll interval.
309
+ */
310
+ async replayRequest(
311
+ requestId: string,
312
+ modifications: Readonly<Record<string, unknown>> | undefined,
313
+ signal: AbortSignal,
314
+ budget: { readonly dispatchTimeoutMs: number; readonly pollIntervalMs: number },
315
+ ): Promise<ReplayOutcome | null> {
316
+ const stored = await this.getRequest(requestId, signal)
317
+ if (stored === null || stored.requestRaw === null) return null
318
+ const components = parseRawRequest(stored.requestRaw)
319
+ const fullUrl = fullUrlFromComponents({ host: stored.host, tls: stored.tls }, components, modifications ?? {})
320
+ const modified = applyModifications(components, modifications ?? {}, fullUrl)
321
+ const built = buildRawRequest(modified)
322
+
323
+ const session = await graphql<{ createReplaySession: { session: { id: string } | null; error: unknown } }>(
324
+ this.options,
325
+ CREATE_REPLAY_SESSION_DOC,
326
+ { input: {} },
327
+ signal,
328
+ )
329
+ const createdSession = session.createReplaySession.session
330
+ if (createdSession === null || createdSession === undefined) throw new Error('createReplaySession returned no session')
331
+
332
+ const started = Date.now()
333
+ const start = await graphql<{ startReplayTask: { error: unknown; task: { id: string; replayEntry: { id: string } | null } | null } }>(
334
+ this.options,
335
+ START_REPLAY_TASK_DOC,
336
+ {
337
+ sessionId: createdSession.id,
338
+ input: {
339
+ connection: { host: built.connection.host, port: built.connection.port, isTLS: built.connection.tls, SNI: null },
340
+ raw: Buffer.from(built.raw).toString('base64'),
341
+ settings: { connectionClose: false, updateContentLength: true, placeholders: [] },
342
+ },
343
+ },
344
+ signal,
345
+ )
346
+ const task = start.startReplayTask.task
347
+ if (start.startReplayTask.error !== null && start.startReplayTask.error !== undefined) {
348
+ throw new Error(`startReplayTask failed: ${JSON.stringify(start.startReplayTask.error).slice(0, 200)}`)
349
+ }
350
+ if (task === null || task === undefined || task.replayEntry === null || task.replayEntry === undefined) {
351
+ throw new Error('startReplayTask returned no task/entry')
352
+ }
353
+
354
+ // Bounded poll (SDK waits on a subscription; polling keeps us HTTP-only).
355
+ for (;;) {
356
+ const entry = await graphql<{ replayEntry: Record<string, unknown> | null }>(
357
+ this.options,
358
+ REPLAY_ENTRY_DOC,
359
+ { id: task.replayEntry.id, includeReplayRaw: false, includeRequestRaw: false, includeResponseRaw: true },
360
+ signal,
361
+ )
362
+ const node = entry.replayEntry
363
+ if (node !== null && node !== undefined) {
364
+ const errorText = node.error === null || node.error === undefined ? undefined : String(node.error)
365
+ const request = node.request as { response?: Record<string, unknown> | null } | null | undefined
366
+ const responseNode = request?.response ?? null
367
+ if (responseNode !== null && responseNode !== undefined) {
368
+ const rawBase64 = responseNode.raw
369
+ const rawBytes = rawBase64 === null || rawBase64 === undefined ? null : Buffer.from(String(rawBase64), 'base64')
370
+ return {
371
+ sessionId: createdSession.id,
372
+ status: 'DONE',
373
+ elapsedMs: Date.now() - started,
374
+ ...(errorText !== undefined ? { error: errorText } : {}),
375
+ response: parseRawResponse(rawBytes),
376
+ }
377
+ }
378
+ if (errorText !== undefined) {
379
+ return { sessionId: createdSession.id, status: 'ERROR', elapsedMs: Date.now() - started, error: errorText, response: null }
380
+ }
381
+ }
382
+ if (Date.now() - started > budget.dispatchTimeoutMs) {
383
+ return {
384
+ sessionId: createdSession.id,
385
+ status: 'ERROR',
386
+ elapsedMs: Date.now() - started,
387
+ error: `Caido replay dispatch did not complete within ${String(Math.round(budget.dispatchTimeoutMs / 1000))}s — the target may be unroutable from the sandbox, or Caido's outbound HTTP client is stalled; check the target host/port and retry`,
388
+ response: null,
389
+ }
390
+ }
391
+ await new Promise(resolve => setTimeout(resolve, budget.pollIntervalMs))
392
+ }
393
+ }
394
+
395
+ /** Sitemap roots or descendants (strix list_sitemap_with_client). */
396
+ async listSitemap(options: {
397
+ readonly scopeId?: string | undefined
398
+ readonly parentId?: string | undefined
399
+ readonly depth?: 'DIRECT' | 'ALL' | undefined
400
+ }, signal: AbortSignal): Promise<unknown> {
401
+ if (options.parentId !== undefined && options.parentId !== '') {
402
+ return graphql(this.options, SITEMAP_DESCENDANTS_DOC, { parentId: options.parentId, depth: options.depth ?? 'DIRECT' }, signal)
403
+ }
404
+ return graphql(this.options, SITEMAP_ROOTS_DOC, options.scopeId !== undefined && options.scopeId !== '' ? { scopeId: options.scopeId } : {}, signal)
405
+ }
406
+
407
+ /** One sitemap entry with its 30 most recent requests (strix view_sitemap_entry_with_client). */
408
+ async viewSitemapEntry(entryId: string, signal: AbortSignal): Promise<unknown> {
409
+ return graphql(this.options, SITEMAP_ENTRY_DOC, { id: entryId }, signal)
410
+ }
411
+
412
+ /** All Caido scopes (strix scope_list). */
413
+ async scopeList(signal: AbortSignal): Promise<unknown> {
414
+ return graphql(this.options, SCOPES_DOC, {}, signal)
415
+ }
416
+
417
+ /** One scope by id (strix scope_get). */
418
+ async scopeGet(scopeId: string, signal: AbortSignal): Promise<unknown> {
419
+ return graphql(this.options, SCOPE_DOC, { id: scopeId }, signal)
420
+ }
421
+
422
+ /** Create a scope; empty lists allow-all/deny-none (strix scope_create). */
423
+ async scopeCreate(name: string, allowlist: string[], denylist: string[], signal: AbortSignal): Promise<unknown> {
424
+ return graphql(this.options, CREATE_SCOPE_DOC, { input: { name, allowlist, denylist } }, signal)
425
+ }
426
+
427
+ /** Update a scope; allow/deny lists FULLY REPLACE the previous values (strix parity). */
428
+ async scopeUpdate(scopeId: string, name: string, allowlist: string[], denylist: string[], signal: AbortSignal): Promise<unknown> {
429
+ return graphql(this.options, UPDATE_SCOPE_DOC, { id: scopeId, input: { name, allowlist, denylist } }, signal)
430
+ }
431
+
432
+ /** Delete a scope (strix scope_delete). */
433
+ async scopeDelete(scopeId: string, signal: AbortSignal): Promise<unknown> {
434
+ return graphql(this.options, DELETE_SCOPE_DOC, { id: scopeId }, signal)
435
+ }
436
+ }